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

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

1
internal/apps-script/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.clasp.json

View File

@@ -0,0 +1,23 @@
# tldraw Google Add-ons
This is an experimental/in-development library to create add-ons for Google Meet.
## Contribution
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
## 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.
## Contact
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
## 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).

View File

@@ -0,0 +1,33 @@
{
"addOns": {
"common": {
"layoutProperties": {
"primaryColor": "#000",
"secondaryColor": "#fff"
},
"logoUrl": "TLDRAW_HOST/android-chrome-maskable-512x512.png",
"name": "tldraw"
},
"meet": {
"web": {
"sidePanelUri": "TLDRAW_HOST/ts-side",
"mainStageUri": "TLDRAW_HOST/ts",
"supportsScreenSharing": true,
"logoUrl": "TLDRAW_HOST/android-chrome-maskable-512x512.png",
"addOnOrigins": ["TLDRAW_HOST"]
}
}
},
"timeZone": "Europe/London",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/documents.currentonly",
"https://www.googleapis.com/auth/script.external_request",
"https://www.googleapis.com/auth/workspace.linkpreview"
],
"urlFetchWhitelist": ["TLDRAW_HOST/"]
}

View File

@@ -0,0 +1,30 @@
import { mkdirSync, writeFileSync } from 'fs'
import path from 'path'
import { exec } from '../../internal/scripts/lib/exec'
import { readFileIfExists } from '../../internal/scripts/lib/file'
async function build() {
await exec('rm', ['-rf', 'dist'])
mkdirSync('dist')
const appsScriptPath = './appsscript.json'
await exec('cp', ['-r', appsScriptPath, 'dist'])
const isProduction = process.env.IS_PRODUCTION === '1'
const scriptId = isProduction
? '1FWcAvz7Rl4iPXQX3KmXm2mNG_RK2kryS7Bja8Y7RHvuAHnic51p_pqe7'
: '1cJfZM0M_rGU-nYgG-4KR1DnERb7itkCsl1QmlqPxFvHnrz5n6Gfy8iht'
writeFileSync('./.clasp.json', `{"scriptId":"${scriptId}","rootDir":"./dist"}`)
const host = isProduction ? 'https://www.tldraw.com' : 'https://staging.tldraw.com'
await replaceInFile(appsScriptPath, 'TLDRAW_HOST', host)
}
async function replaceInFile(filename: string, searchValue: string, replaceValue: string) {
let contents = (await readFileIfExists(path.join('dist', filename))) ?? ''
contents = contents.replaceAll(searchValue, replaceValue)
writeFileSync(path.join('dist', filename), contents)
}
build()

View File

@@ -0,0 +1,47 @@
{
"name": "apps-scripts",
"description": "tldraw for Google Apps",
"version": "0.0.0",
"private": true,
"author": {
"name": "tldraw Inc.",
"email": "hello@tldraw.com"
},
"homepage": "https://tldraw.dev",
"license": "SEE LICENSE IN LICENSE.md",
"repository": {
"type": "git",
"url": "https://github.com/tldraw/tldraw"
},
"bugs": {
"url": "https://github.com/tldraw/tldraw/issues"
},
"keywords": [
"tldraw",
"sdk",
"drawing",
"app",
"development",
"whiteboard",
"canvas",
"infinite"
],
"scripts": {
"lint": "yarn run -T tsx ../scripts/lint.ts",
"glogin": "clasp login",
"glogout": "clasp logout",
"gcreate": "clasp create --type standalone --title \"tldraw.com\" --rootDir ./dist",
"gpull": "yarn build && clasp pull",
"gpush": "yarn build && clasp push",
"gpull:staging": "yarn build:staging && clasp pull",
"gpush:staging": "yarn build:staging && clasp push",
"build": "IS_PRODUCTION=1 yarn run -T tsx build-workspace-app.ts",
"build:staging": "yarn run -T tsx build-workspace-app.ts"
},
"dependencies": {
"@google/clasp": "^3.2.0"
},
"devDependencies": {
"@types/google-apps-script": "^1.0.91"
}
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"target": "esnext",
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["node"],
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }]
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"],
"references": []
}

View File

@@ -0,0 +1 @@
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)

View File

@@ -0,0 +1,406 @@
/**
* Config file for API Extractor. For more info, please visit: https://api-extractor.com
*/
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
/**
* Optionally specifies another JSON config file that this file extends from. This provides a way for
* standard settings to be shared across multiple projects.
*
* If the path starts with "./" or "../", the path is resolved relative to the folder of the file that contains
* the "extends" field. Otherwise, the first path segment is interpreted as an NPM package name, and will be
* resolved using NodeJS require().
*
* SUPPORTED TOKENS: none
* DEFAULT VALUE: ""
*/
// "extends": "./shared/api-extractor-base.json"
// "extends": "my-package/include/api-extractor-base.json"
/**
* Determines the "<projectFolder>" token that can be used with other config file settings. The project folder
* typically contains the tsconfig.json and package.json config files, but the path is user-defined.
*
* The path is resolved relative to the folder of the config file that contains the setting.
*
* The default value for "projectFolder" is the token "<lookup>", which means the folder is determined by traversing
* parent folders, starting from the folder containing api-extractor.json, and stopping at the first folder
* that contains a tsconfig.json file. If a tsconfig.json file cannot be found in this way, then an error
* will be reported.
*
* SUPPORTED TOKENS: <lookup>
* DEFAULT VALUE: "<lookup>"
*/
"projectFolder": "../..",
/**
* (REQUIRED) Specifies the .d.ts file to be used as the starting point for analysis. API Extractor
* analyzes the symbols exported by this module.
*
* The file extension must be ".d.ts" and not ".ts".
*
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
* prepend a folder token such as "<projectFolder>".
*
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
*/
"mainEntryPointFilePath": "<projectFolder>/packages/<unscopedPackageName>/.tsbuild-api/index.d.ts",
/**
* A list of NPM package names whose exports should be treated as part of this package.
*
* For example, suppose that Webpack is used to generate a distributed bundle for the project "library1",
* and another NPM package "library2" is embedded in this bundle. Some types from library2 may become part
* of the exported API for library1, but by default API Extractor would generate a .d.ts rollup that explicitly
* imports library2. To avoid this, we can specify:
*
* "bundledPackages": [ "library2" ],
*
* This would direct API Extractor to embed those types directly in the .d.ts rollup, as if they had been
* local files for library1.
*/
"bundledPackages": [],
/**
* Specifies what type of newlines API Extractor should use when writing output files. By default, the output files
* will be written with Windows-style newlines. To use POSIX-style newlines, specify "lf" instead.
* To use the OS's default newline kind, specify "os".
*
* DEFAULT VALUE: "crlf"
*/
"newlineKind": "lf",
/**
* Set to true when invoking API Extractor's test harness. When `testMode` is true, the `toolVersion` field in the
* .api.json file is assigned an empty string to prevent spurious diffs in output files tracked for tests.
*
* DEFAULT VALUE: "false"
*/
// "testMode": false,
/**
* Specifies how API Extractor sorts members of an enum when generating the .api.json file. By default, the output
* files will be sorted alphabetically, which is "by-name". To keep the ordering in the source code, specify
* "preserve".
*
* DEFAULT VALUE: "by-name"
*/
// "enumMemberOrder": "by-name",
/**
* Determines how the TypeScript compiler engine will be invoked by API Extractor.
*/
"compiler": {
/**
* Specifies the path to the tsconfig.json file to be used by API Extractor when analyzing the project.
*
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
* prepend a folder token such as "<projectFolder>".
*
* Note: This setting will be ignored if "overrideTsconfig" is used.
*
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
* DEFAULT VALUE: "<projectFolder>/tsconfig.json"
*/
"tsconfigFilePath": "<projectFolder>/packages/<unscopedPackageName>/tsconfig.json"
/**
* Provides a compiler configuration that will be used instead of reading the tsconfig.json file from disk.
* The object must conform to the TypeScript tsconfig schema:
*
* http://json.schemastore.org/tsconfig
*
* If omitted, then the tsconfig.json file will be read from the "projectFolder".
*
* DEFAULT VALUE: no overrideTsconfig section
*/
// "overrideTsconfig": {
// . . .
// }
/**
* This option causes the compiler to be invoked with the --skipLibCheck option. This option is not recommended
* and may cause API Extractor to produce incomplete or incorrect declarations, but it may be required when
* dependencies contain declarations that are incompatible with the TypeScript engine that API Extractor uses
* for its analysis. Where possible, the underlying issue should be fixed rather than relying on skipLibCheck.
*
* DEFAULT VALUE: false
*/
// "skipLibCheck": true,
},
/**
* Configures how the API report file (*.api.md) will be generated.
*/
"apiReport": {
/**
* (REQUIRED) Whether to generate an API report.
*/
"enabled": true,
/**
* The filename for the API report files. It will be combined with "reportFolder" or "reportTempFolder" to produce
* a full file path.
*
* The file extension should be ".api.md", and the string should not contain a path separator such as "\" or "/".
*
* SUPPORTED TOKENS: <packageName>, <unscopedPackageName>
* DEFAULT VALUE: "<unscopedPackageName>.api.md"
*/
"reportFileName": "api-report",
/**
* Specifies the folder where the API report file is written. The file name portion is determined by
* the "reportFileName" setting.
*
* The API report file is normally tracked by Git. Changes to it can be used to trigger a branch policy,
* e.g. for an API review.
*
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
* prepend a folder token such as "<projectFolder>".
*
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
* DEFAULT VALUE: "<projectFolder>/temp/"
*/
"reportFolder": "<projectFolder>/packages/<unscopedPackageName>/",
/**
* Specifies the folder where the temporary report file is written. The file name portion is determined by
* the "reportFileName" setting.
*
* After the temporary file is written to disk, it is compared with the file in the "reportFolder".
* If they are different, a production build will fail.
*
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
* prepend a folder token such as "<projectFolder>".
*
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
* DEFAULT VALUE: "<projectFolder>/temp/"
*/
"reportTempFolder": "<projectFolder>/packages/<unscopedPackageName>/api/temp"
/**
* Whether "forgotten exports" should be included in the API report file. Forgotten exports are declarations
* flagged with `ae-forgotten-export` warnings. See https://api-extractor.com/pages/messages/ae-forgotten-export/ to
* learn more.
*
* DEFAULT VALUE: "false"
*/
// "includeForgottenExports": false
},
/**
* Configures how the doc model file (*.api.json) will be generated.
*/
"docModel": {
/**
* (REQUIRED) Whether to generate a doc model file.
*/
"enabled": true,
/**
* The output path for the doc model file. The file extension should be ".api.json".
*
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
* prepend a folder token such as "<projectFolder>".
*
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
* DEFAULT VALUE: "<projectFolder>/temp/<unscopedPackageName>.api.json"
*/
"apiJsonFilePath": "<projectFolder>/packages/<unscopedPackageName>/api/api.json",
/**
* Whether "forgotten exports" should be included in the doc model file. Forgotten exports are declarations
* flagged with `ae-forgotten-export` warnings. See https://api-extractor.com/pages/messages/ae-forgotten-export/ to
* learn more.
*
* DEFAULT VALUE: "false"
*/
// "includeForgottenExports": false,
/**
* The base URL where the project's source code can be viewed on a website such as GitHub or
* Azure DevOps. This URL path corresponds to the `<projectFolder>` path on disk.
*
* This URL is concatenated with the file paths serialized to the doc model to produce URL file paths to individual API items.
* For example, if the `projectFolderUrl` is "https://github.com/microsoft/rushstack/tree/main/apps/api-extractor" and an API
* item's file path is "api/ExtractorConfig.ts", the full URL file path would be
* "https://github.com/microsoft/rushstack/tree/main/apps/api-extractor/api/ExtractorConfig.js".
*
* Can be omitted if you don't need source code links in your API documentation reference.
*
* SUPPORTED TOKENS: none
* DEFAULT VALUE: ""
*/
"projectFolderUrl": "https://github.com/tldraw/tldraw/blob/main"
},
/**
* Configures how the .d.ts rollup file will be generated.
*/
"dtsRollup": {
/**
* (REQUIRED) Whether to generate the .d.ts rollup file.
*/
"enabled": true,
/**
* Specifies the output path for a .d.ts rollup file to be generated without any trimming.
* This file will include all declarations that are exported by the main entry point.
*
* If the path is an empty string, then this file will not be written.
*
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
* prepend a folder token such as "<projectFolder>".
*
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
* DEFAULT VALUE: "<projectFolder>/dist/<unscopedPackageName>.d.ts"
*/
"untrimmedFilePath": "<projectFolder>/packages/<unscopedPackageName>/api/internal.d.ts",
/**
* Specifies the output path for a .d.ts rollup file to be generated with trimming for an "alpha" release.
* This file will include only declarations that are marked as "@public", "@beta", or "@alpha".
*
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
* prepend a folder token such as "<projectFolder>".
*
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
* DEFAULT VALUE: ""
*/
// "alphaTrimmedFilePath": "0.0.0",
/**
* Specifies the output path for a .d.ts rollup file to be generated with trimming for a "beta" release.
* This file will include only declarations that are marked as "@public" or "@beta".
*
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
* prepend a folder token such as "<projectFolder>".
*
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
* DEFAULT VALUE: ""
*/
// "betaTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-beta.d.ts",
/**
* Specifies the output path for a .d.ts rollup file to be generated with trimming for a "public" release.
* This file will include only declarations that are marked as "@public".
*
* If the path is an empty string, then this file will not be written.
*
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
* prepend a folder token such as "<projectFolder>".
*
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
* DEFAULT VALUE: ""
*/
"publicTrimmedFilePath": "<projectFolder>/packages/<unscopedPackageName>/api/public.d.ts"
/**
* When a declaration is trimmed, by default it will be replaced by a code comment such as
* "Excluded from this release type: exampleMember". Set "omitTrimmingComments" to true to remove the
* declaration completely.
*
* DEFAULT VALUE: false
*/
// "omitTrimmingComments": true
},
/**
* Configures how the tsdoc-metadata.json file will be generated.
*/
"tsdocMetadata": {
/**
* Whether to generate the tsdoc-metadata.json file.
*
* DEFAULT VALUE: true
*/
"enabled": false
/**
* Specifies where the TSDoc metadata file should be written.
*
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
* prepend a folder token such as "<projectFolder>".
*
* The default value is "<lookup>", which causes the path to be automatically inferred from the "tsdocMetadata",
* "typings" or "main" fields of the project's package.json. If none of these fields are set, the lookup
* falls back to "tsdoc-metadata.json" in the package folder.
*
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
* DEFAULT VALUE: "<lookup>"
*/
// "tsdocMetadataFilePath": "<projectFolder>/dist/tsdoc-metadata.json"
},
/**
* Configures how API Extractor reports error and warning messages produced during analysis.
*
* There are three sources of messages: compiler messages, API Extractor messages, and TSDoc messages.
*/
"messages": {
/**
* Configures handling of diagnostic messages reported by the TypeScript compiler engine while analyzing
* the input .d.ts files.
*
* TypeScript message identifiers start with "TS" followed by an integer. For example: "TS2551"
*
* DEFAULT VALUE: A single "default" entry with logLevel=warning.
*/
"compilerMessageReporting": {
/**
* Configures the default routing for messages that don't match an explicit rule in this table.
*/
"default": {
/**
* Specifies whether the message should be written to the the tool's output log. Note that
* the "addToApiReportFile" property may supersede this option.
*
* Possible values: "error", "warning", "none"
*
* Errors cause the build to fail and return a nonzero exit code. Warnings cause a production build fail
* and return a nonzero exit code. For a non-production build (e.g. when "api-extractor run" includes
* the "--local" option), the warning is displayed but the build will not fail.
*
* DEFAULT VALUE: "warning"
*/
"logLevel": "error"
/**
* When addToApiReportFile is true: If API Extractor is configured to write an API report file (.api.md),
* then the message will be written inside that file; otherwise, the message is instead logged according to
* the "logLevel" option.
*
* DEFAULT VALUE: false
*/
// "addToApiReportFile": false
}
// "TS2551": {
// "logLevel": "warning",
// "addToApiReportFile": true
// },
//
// . . .
},
/**
* Configures handling of messages reported by API Extractor during its analysis.
*
* API Extractor message identifiers start with "ae-". For example: "ae-extra-release-tag"
*
* DEFAULT VALUE: See api-extractor-defaults.json for the complete table of extractorMessageReporting mappings
*/
"extractorMessageReporting": {
"default": {
"logLevel": "error"
},
"ae-internal-missing-underscore": {
"logLevel": "none",
"addToApiReportFile": false
},
"ae-forgotten-export": {
"logLevel": "error",
"addToApiReportFile": false
},
"ae-wrong-input-file-type": {
"logLevel": "none"
}
// "ae-extra-release-tag": {
// "logLevel": "warning",
// "addToApiReportFile": true
// },
//
// . . .
},
/**
* Configures handling of messages reported by the TSDoc parser when analyzing code comments.
*
* TSDoc message identifiers start with "tsdoc-". For example: "tsdoc-link-tag-unescaped-text"
*
* DEFAULT VALUE: A single "default" entry with logLevel=warning.
*/
"tsdocMessageReporting": {
"default": {
"logLevel": "error"
// "addToApiReportFile": false
}
// "tsdoc-link-tag-unescaped-text": {
// "logLevel": "warning",
// "addToApiReportFile": true
// },
//
// . . .
}
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "config",
"version": "0.0.0",
"main": "index.js",
"license": "SEE LICENSE IN LICENSE.md",
"private": true,
"devDependencies": {
"@jest/expect-utils": "^30.0.5",
"@peculiar/webcrypto": "^1.5.0",
"jest-matcher-utils": "^30.0.5",
"lazyrepo": "0.0.0-alpha.27",
"vitest": "^5.0.0",
"vitest-canvas-mock": "^1.1.3"
}
}

View File

@@ -0,0 +1,37 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Default",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"importHelpers": true,
"resolveJsonModule": true,
"incremental": true,
"jsx": "react-jsx",
"jsxImportSource": "react",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "bundler",
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUncheckedSideEffectImports": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"skipLibCheck": true,
"strict": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"useDefineForClassFields": true,
"noImplicitOverride": true,
"experimentalDecorators": true,
"types": ["node", "vitest/globals"]
},
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"include": ["vitest"],
"exclude": ["node_modules", ".tsbuild*"],
"compilerOptions": { "outDir": "./.tsbuild", "rootDir": "." }
}

View File

@@ -0,0 +1,51 @@
import path from 'path'
import { defineConfig } from 'vitest/config'
const svgTransform = {
name: 'svg-transform',
transform(_code: string, id: string) {
if (id.endsWith('.svg')) {
return {
code: 'export default {};',
map: null,
}
}
return undefined
},
}
export default defineConfig({
plugins: [svgTransform],
test: {
globals: true,
environment: 'jsdom',
// Persist transformed modules to node_modules/.vitest-cache so reruns skip re-transforming.
// Only helps a warm cache: a cold run (fresh clone, cleared node_modules) pays full price.
fsModuleCache: true,
// Worker threads start much faster than forked processes. Projects needing real process
// isolation (e.g. sync-worker, which passes node flags via execArgv) override this.
pool: 'threads',
setupFiles: [path.resolve(__dirname, './setup.ts')],
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
exclude: [
'**/test/__fixtures__/**',
'**/node_modules/**',
'**/dist/**',
'**/.tsbuild/**',
'**/.tsbuild-dev/**',
'**/.tsbuild-pub/**',
],
coverage: {
include: ['src/**/*.{ts,tsx}'],
exclude: ['**/*.test.*', '**/*.spec.*', '**/test/**', '**/__tests__/**'],
},
},
resolve: {
alias: {
'~': path.resolve(process.cwd(), 'src'),
},
},
ssr: {
noExternal: ['@tiptap/react'],
},
})

View File

@@ -0,0 +1,143 @@
import { equals, getObjectSubset, iterableEquality, subsetEquality } from '@jest/expect-utils'
import {
matcherHint,
printDiffOrStringify,
printExpected,
printReceived,
stringify,
} from 'jest-matcher-utils'
if (typeof window !== 'undefined') {
await import('vitest-canvas-mock')
}
// Crypto fallback for environments without a native WebCrypto implementation (e.g. the ai package).
// jsdom provides window.crypto with subtle crypto, so this only kicks in elsewhere.
if (typeof globalThis.crypto === 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Crypto } = require('@peculiar/webcrypto')
globalThis.crypto = new Crypto()
}
// Image.decode polyfill - this should be handled by individual package setups,
// but including it here as a fallback
if (typeof HTMLImageElement !== 'undefined') {
if (!HTMLImageElement.prototype.decode) {
HTMLImageElement.prototype.decode = function () {
return Promise.resolve()
}
}
}
// Path2D polyfills
if (typeof Path2D !== 'undefined' && typeof Path2D.prototype.roundRect !== 'function') {
Path2D.prototype.roundRect = function (x, y, w, h, _) {
this.rect(x, y, w, h)
}
}
// CSS.supports polyfill for tests that use color spaces (e.g., highlight shapes)
if (typeof CSS === 'undefined') {
;(global as any).CSS = {}
}
if (typeof CSS.supports === 'undefined') {
CSS.supports = () => false
}
// Pointer capture polyfill. jsdom implements the PointerEvent constructor but not the pointer
// capture model, so setPointerCapture/releasePointerCapture/hasPointerCapture are missing
// (https://github.com/jsdom/jsdom/pull/2666). Our canvas event handlers capture the pointer on
// pointerdown/up, so stub them out to avoid throwing.
if (typeof Element !== 'undefined') {
Element.prototype.setPointerCapture ??= function () {
// noop
}
Element.prototype.releasePointerCapture ??= function () {
// noop
}
Element.prototype.hasPointerCapture ??= function () {
return false
}
}
// Captured before any test file installs fake timers.
const realSetTimeout = globalThis.setTimeout
const realSetImmediate = globalThis.setImmediate
// Drain deferred React work before vitest tears down jsdom: exportToSvg unmounts its root in a
// setTimeout(0) and every react-dom commit schedules a setImmediate that reads `window.event`.
// Landing after teardown, those throw "window is not defined" as an unhandled error (flaky CI).
if (typeof window !== 'undefined') {
afterAll(async () => {
// two passes: pass 1 fires pending timeouts and the setImmediate they enqueue; pass 2 covers
// the scheduler re-arming itself when it yields mid-work
for (let i = 0; i < 2; i++) {
await new Promise((resolve) => realSetTimeout(resolve, 0))
await new Promise((resolve) => realSetImmediate(resolve))
}
})
}
function convertNumbersInObject(obj: any, roundToNearest: number): any {
if (!obj) return obj
if (Array.isArray(obj)) {
return obj.map((x) => convertNumbersInObject(x, roundToNearest))
}
if (typeof obj === 'number') {
// || 0 to avoid -0
return Math.round(obj / roundToNearest) * roundToNearest || 0
}
if (typeof obj !== 'object') {
return obj
}
const r: any = {
__converted: true,
}
for (const k of Object.keys(obj)) {
r[k] = convertNumbersInObject(obj[k], roundToNearest)
}
return r
}
expect.extend({
toCloselyMatchObject(actual: any, expected: any, roundToNearest = 0.0001) {
const matcherName = 'toCloselyMatchObject'
const options = {
isNot: this.isNot,
promise: this.promise,
}
const EXPECTED_LABEL = 'Expected'
const RECEIVED_LABEL = 'Received'
const newActualObj = convertNumbersInObject(actual, roundToNearest)
const newExpectedObj = convertNumbersInObject(expected, roundToNearest)
const pass = equals(newActualObj, newExpectedObj, [iterableEquality, subsetEquality])
const message = pass
? () =>
matcherHint(matcherName, undefined, undefined, options) +
'\n\n' +
`Expected: not ${printExpected(expected)}` +
(stringify(expected) !== stringify(actual)
? `\nReceived: ${printReceived(actual)}`
: '')
: () =>
matcherHint(matcherName, undefined, undefined, options) +
'\n\n' +
printDiffOrStringify(
expected,
getObjectSubset(actual, expected),
EXPECTED_LABEL,
RECEIVED_LABEL,
true
)
return { message, pass }
},
})

View File

@@ -0,0 +1,83 @@
# Product stable ids
Every published package in `packages/` declares which commercial component it belongs to via a `tldraw_product` field in its `package.json`. These stable ids are what legal and sales reference in order forms, so they must be unambiguous and durable: renaming, moving, or refactoring code never changes a stable id.
## The shape of the taxonomy
The taxonomy has exactly two levels, matching the order form's "Product in Scope" section:
- **Products** are the top-level line items — the checkboxes a customer ticks. Today: SDK and the Collaboration module.
- **Features** are parts of a product that can be licensed on their own, listed indented beneath their parent. Commenting is a feature of the Collaboration module.
```
[ ] SDK
[ ] Sync, Driver, Mermaid, Create tldraw CLI (included, not separately licensed)
[ ] Collaboration module
[ ] Commenting
```
A feature that isn't premium is _included_ in its parent rather than separately licensable — it appears in the manifest so legal can see what a product contains, but it isn't its own checkbox.
Individual npm packages are not part of this taxonomy. A package declares the component it implements and inherits that component's entitlement; several packages can (and do) share one stable id. `@tldraw/commenting`, `@tldraw/mentions`, and `@tldraw/sync-collaboration` all implement `tldraw:commenting`, and none of them are order form line items.
## Why package.json
- npm publishes `package.json` verbatim and published versions are immutable, so every shipped version carries a permanent record of its stable id and premium status. "What did `@tldraw/commenting@5.3.0` claim to be" is answerable forever via the npm registry (for example `https://registry.npmjs.org/@tldraw/commenting/latest`).
- The metadata travels with the package through renames and moves.
- The release process already rewrites every package's `package.json`, so no separate manifest file can drift out of date.
## The `tldraw_product` field
```json
"tldraw_product": {
"stableId": "tldraw:commenting",
"name": "Commenting",
"type": "feature",
"parent": "tldraw:collaboration",
"premium": true,
"licenseFlag": "FEAT_COMMENTING"
}
```
- `stableId` — immutable commercial identifier, `tldraw:<kebab-case>`. This is what contracts reference.
- `name` — the customer-facing label, matching the wording on the order form. Unlike the stable id this may be reworded.
- `type` — `product` (top-level line item) or `feature` (part of a parent).
- `parent` — for features, the component they belong to.
- `premium` — whether the component is separately licensed rather than part of the standard SDK offering.
- `licenseFlag` — for premium components, the license key `FLAGS` bit in `packages/editor/src/lib/license/LicenseManager.ts` that entitles a customer to the component. This is the bridge between order forms and runtime enforcement: a license key whose flags include this bit grants the component.
## Rules
- A stable id never changes when code is renamed, moved, split, or refactored. If a package is renamed, the `tldraw_product` field moves with it unchanged.
- A new stable id is minted only for a genuinely new commercial component — something sales would list on an order form. Major version bumps do not mint new ids; the order form grants "all versions made available during the Order Form Term", so version entitlement is handled by the term and the license key's expiry, never by the id.
- Moving a package to a different component is the expensive operation, because customers have signed order forms citing the old one. When a package is likely to become separately licensable later, give it its own component up front.
- All packages sharing a stable id must have identical metadata.
- Every published package in `packages/` must have the field. `yarn check-packages` (run in CI) enforces this, validates `licenseFlag` values against `LicenseManager`, checks that every `parent` resolves, and checks that packages pointing at `LICENSE.md` actually ship one.
## How legal references a component
Reference components by stable id, for example: "SDK (`tldraw:sdk-core`) and the Collaboration module (`tldraw:collaboration`), including Commenting (`tldraw:commenting`)".
- `yarn product-manifest` prints the manifest as a JSON tree: products, their features, and the packages implementing each.
- For any published version, the npm registry copy of each package's `package.json` is the immutable record.
## Current components
Run `yarn product-manifest` for the authoritative list. As of the introduction of this convention:
| Stable id | Name | Parent | Premium | Packages |
| ---------------------- | -------------------- | ---------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tldraw:sdk-core` | SDK | — | no | `tldraw`, `@tldraw/tldraw`, `@tldraw/editor`, `@tldraw/assets`, `@tldraw/state`, `@tldraw/state-react`, `@tldraw/store`, `@tldraw/tlschema`, `@tldraw/utils`, `@tldraw/validate` |
| `tldraw:sync` | Sync | `tldraw:sdk-core` | no | `@tldraw/sync`, `@tldraw/sync-core` |
| `tldraw:driver` | Driver | `tldraw:sdk-core` | no | `@tldraw/driver` |
| `tldraw:mermaid` | Mermaid | `tldraw:sdk-core` | no | `@tldraw/mermaid` |
| `tldraw:create-tldraw` | Create tldraw CLI | `tldraw:sdk-core` | no | `create-tldraw` |
| `tldraw:collaboration` | Collaboration module | — | yes (`FEAT_COLLABORATION`) | `@tldraw/collaboration` |
| `tldraw:commenting` | Commenting | `tldraw:collaboration` | yes (`FEAT_COMMENTING`) | `@tldraw/commenting`, `@tldraw/mentions`, `@tldraw/sync-collaboration` |
Entitlement flows down the tree:
- `FEAT_COLLABORATION` — the umbrella flag; grants the whole Collaboration module.
- `FEAT_COMMENTING` — grants commenting.
`@tldraw/mentions` implements commenting today because `@tldraw/commenting` depends on it, so every customer entitled to commenting is entitled to mentions. If mentions later becomes usable on its own — in shape rich text, say — it will need its own component and license flag at that point.

View File

@@ -0,0 +1,77 @@
# Grafana dashboards and alerts
**TL;DR:** `resources/` is the source of truth for our Grafana Cloud dashboards and alert rules. CI pushes it with `gcx` on every merge to `main` that touches these files. Edit here, not in Grafana — UI edits get overwritten by the next deploy.
This directory currently provisions:
- Eleven dashboards: `Zero on Fly.io — service health` (`zero-fly-health`), `Zero slow queries` (`slow-queries`), and `Zero-cache HTTP edge (Fly.io)` (`ni7hhgd`) in the `Zero` folder; `Anthropic scrape overview` in the `Anthropic` folder; `Dotcom events` (`ni5k8zc`), `Dotcom events V1` (`adgumkhou3chsd`), `File effect outbox`, `MCP server (shared boards)` (`mcp-server`), `MCP app sessions` (`kotx6wj`), and `Bemo analytics` in the `Dotcom` folder; and `Cloudflare platform metrics` (`nivs2rl`), which sits at the root rather than in a folder. The events pipeline currently reports from **staging** — set the environment variable accordingly when a dashboard looks empty.
- All 13 Grafana-managed alert rules (Zero replication/errors/backups, Fly.io container health, Anthropic cost/usage, file room DO exceptions)
- The folders `Zero` (dashboards + 9 rules), `Dotcom` (6 dashboards + 1 rule), and `Anthropic` (1 dashboard + 3 rules, see below)
- The `discord` and `email` notification templates (`templates/*.gotmpl`, defining `tldraw.discord.title` / `tldraw.discord.text` and `tldraw.email.subject` / `tldraw.email.message`), which compress alert notifications to severity + summary + links (no Silence link on Discord: silence URLs carry one matcher per label and blow Discord’s 2000-char message cap on multi-instance alerts, truncating mid-link) and explain `DatasourceNoData` as a likely metrics-source outage. Templates can't go through `gcx resources push` — gcx ignores the `notifications.alerting.grafana.app` API group — so they live in `templates/` instead of `resources/` and the deploy workflow PUTs them through the classic provisioning API. Each contact point's optional Title/Subject and Message fields reference these templates; those references are set by hand (contact points stay hand-managed), so the templates only take effect while a contact point points at them.
There are two MCP dashboards, for two different servers. `MCP server (shared boards)` covers the public server on the sync worker at `POST /app/mcp` (`apps/dotcom/sync-worker`), which reads its protocol metrics from the `MEASURE` dataset. `MCP app sessions` covers the separate tldraw MCP app worker (`apps/mcp-app`) and its own `MCP_ANALYTICS` dataset. Neither one's panels belong on `Dotcom events` — that dashboard covers the sync worker's own events.
`Dotcom events V1` holds only the catch-all `Room events` panel and `max attempts before persistence success`; new panels go on `Dotcom events`.
Contact points, notification policies, and data sources are hand-managed in Grafana on purpose.
## Conventions
Every provisioned dashboard carries the tags `provisioned` and `repo:tldraw/tldraw`, so anyone looking at it in Grafana knows where it's managed from. Add both tags when adopting a new dashboard into this tree.
## Anthropic rules
The three rules in the `Anthropic` folder watch the Grafana Anthropic integration's scrape of the Anthropic Admin API. The two metrics behave very differently, and the rules are shaped around that:
- `gen_ai_usage_tokens_total` is near real time and is labelled by API key, model, and token type (`input`, `output`, `cache_creation_5m`, `cache_creation_1h`, `input_cache_read`). About 70 series at the time of writing. This is the only intraday signal.
- `gen_ai_cost` is **month-to-date** spend in cents, with no API key label, updated **once a day at about 03:00 UTC**. It resets on the 1st, and has had no data at all on the 1st. Anything built on it describes the last completed day, sees a runaway up to 27 hours late, and is blind for a day or two at each month rollover.
`AnthropicTokenBurst` (warning) fires when the summed rate across all series, excluding cache reads, exceeds 1000 tokens/sec over an hour (about 3.6M tokens/hour). Three deliberate choices, each learned the hard way:
- **Summed, not per series.** A per-series rule makes one alert instance per key × model × token type, and every lightly used series has a tiny baseline that any real session clears.
- **No baseline.** Usage is bursty and mostly idle (the median hour is under 10 tokens/sec), so a 7-day average is close to zero and "5x the average" is a normal working hour. #10579 and #10582 tuned the multiplier and floor twice and it still paged on ordinary sessions.
- **Cache reads excluded.** They cost a tenth of uncached input and dominate agent and eval traffic. On 2026-09-01 and 09-02 the rule paged repeatedly on Opus 5 cache reads from the internal evals key, which spent about $50 that day. The floor is chosen so that workload stays quiet (it peaks around 500 non-cache-read tokens/sec) while a big-numbers bulk cold-outreach research run (`tldraw-internal`, `crm-dashboard-api`), roughly 2000 tokens/sec of uncached Sonnet 4.5 input at $20 to $25 an hour and about $500 over 2026-08-21 to 08-22, clears it within the hour. Those runs are expected work, so a page from this rule usually means "check whether someone started a bulk run" rather than "something is broken".
`AnthropicDailyCostSpike` (warning) fires when the last completed day cost more than 1.5x the day before, with a $50 floor. `AnthropicHighCostThreshold` (critical) fires when the last completed day cost more than $1000. Both use `increase(gen_ai_cost[1d])` rather than `offset` subtraction so the monthly reset reads as a counter reset and counts from zero instead of going negative. The scrape gap on the 1st is still a blind spot: the first day of the month reads as roughly $0 because there is nothing to diff against. Because the metric only steps once a day, a firing rule stays firing until the next 03:00 update.
All three are filter queries (`expr > threshold`), so the alert series only exists while the condition holds. `missingSeriesEvalsToResolve: 10` is what stops a single quiet minute from resolving and re-firing them.
## Making a change
1. Edit the files under `resources/`.
2. Open a PR. CI validates the resources and dry-runs the push so you can see what would change. (The alerting API doesn't support server-side dry-run, so alert rules show as "skipped: client-side check only" — that's expected, not an error.)
3. Merge. The deploy workflow (`.github/workflows/deploy-grafana.yml`) pushes to Grafana Cloud.
## Adding a new alert rule
A rule file carrying `grafana.com/group` labels cannot be _created_ by the push. Grafana rejects it with `cannot set group when creating a new rule` — the resource API can only update a rule that is already in a group. Nothing catches this earlier: alerting has no server-side dry-run, so the PR reports the rule as "skipped" and goes green.
Bootstrap the group once by hand, then let the push adopt it:
gcx api /api/v1/provisioning/folder/<folder uid>/rule-groups/<group> \
-X PUT -H 'X-Disable-Provenance: true' -d @group.json
`group.json` uses the classic provisioning shape (`{title, folderUid, interval, rules: [...]}`), which is not the shape of the file in this tree: `spec.expressions` becomes a `data` array, the expression carrying `source: true` becomes `condition`, and `spec.trigger.interval` becomes the group's `interval` in seconds. `X-Disable-Provenance: true` is what leaves provenance clear so the push can take ownership afterwards.
`dotcom-file-do-exceptions` was created this way on 2026-08-18, after PR #10052's deploy failed on it.
## Pulling fresh state
If you prototyped a dashboard in the Grafana UI and want to adopt it, pull it into the tree with a local gcx context (`gcx login`):
gcx resources pull dashboards/<uid> -p internal/grafana/resources -o yaml
gcx resources pull alertrules -p internal/grafana/resources -o yaml
Note the alert rules selector is `alertrules` — bare `rules` routes to the Asserts product and 403s on our stack.
Don't hand-reformat gcx output; it needs to round-trip. Exception: `zero-fly-health` is stored as JSON because gcx 1.0.0's YAML writer emits an invalid escape for emoji variation selectors in row titles (its own reader then rejects the file). If a pulled YAML file fails `gcx resources validate`, re-pull that resource with `-o json`.
Alert rules created through Grafana's "import Prometheus rules" flow can't be pushed: they carry `converted_prometheus` provenance, and Grafana only accepts writes to a rule from the tool that owns its provenance. To adopt one, strip the `alerting.grafana.app/prometheus-rule-definition` annotation and set `grafana.com/provenance: ''` in the pulled file, then recreate the rule in Grafana with clear provenance so the push can take ownership: delete the imported group (`gcx api /api/convert/prometheus/config/v1/rules/<folder title>/<group> -X DELETE`), recreate it via `gcx api /api/v1/provisioning/folder/<folder uid>/rule-groups/<group> -X PUT -H 'X-Disable-Provenance: true' -d @group.json`, and push. Recreating grouped rules must go through that group endpoint — the resource API gcx pushes with can't add rules to groups, only update rules already in one. The three Anthropic cost rules went through this adoption on 2026-08-10 and are repo-owned now — don't re-run the integration's rule import for them, which would recreate the imported group alongside the adopted one and bring the provenance clash back.
## Deleting a resource
Deleting a file here does **not** delete it in Grafana. Remove the file and run `gcx resources delete <type>/<uid>` manually.
## Service account access
CI authenticates with a Grafana service account token (`GRAFANA_SERVER` / `GRAFANA_TOKEN` repo secrets). The `zero-fly-health` dashboard has restricted permissions: the service account needs an explicit grant on it (or admin role) or pulls and pushes will 403.

View File

@@ -0,0 +1,112 @@
apiVersion: rules.alerting.grafana.app/v0alpha1
kind: AlertRule
metadata:
annotations:
grafana.app/folder: anthropic
grafana.com/provenance: ''
grafana.com/updateTimestamp: '2026-03-20T11:22:55Z'
grafana.com/updatedBy: bf1wptf89bpq8d
labels:
grafana.app/folder: anthropic
grafana.com/group: anthropic
grafana.com/group-index: '1'
name: 8451fbba-5835-56e2-b2f5-7f5e44c520b8
namespace: stacks-890472
spec:
annotations:
description: 'Anthropic API usage over the last hour averaged {{ $value | humanize }}
tokens/sec excluding cache reads, above the 1000 tokens/sec floor (about 3.6M
tokens/hour). Check the scrape overview dashboard for which key and model.'
summary: Anthropic API token burst detected.
execErrState: Ok
expressions:
prometheus_math:
model:
datasource:
IsPrunable: false
access: ''
apiVersion: ''
basicAuth: false
basicAuthUser: ''
created: '0001-01-01T00:00:00Z'
database: ''
id: -100
isDefault: false
jsonData: {}
name: __expr__
readOnly: false
secureJsonData: {}
type: __expr__
uid: __expr__
updated: '0001-01-01T00:00:00Z'
url: ''
user: ''
withCredentials: false
expression: is_number($query) || is_nan($query) || is_inf($query)
intervalMs: 1000
maxDataPoints: 43200
refId: prometheus_math
type: math
queryType: math
query:
datasourceUID: grafanacloud-prom
model:
datasource:
type: prometheus
uid: grafanacloud-prom
expr: |
sum(
rate(gen_ai_usage_tokens_total{job=~"integrations/anthropic/.*", gen_ai_token_type!="input_cache_read"}[1h])
) > 1000
instant: true
intervalMs: 1000
maxDataPoints: 43200
range: false
refId: query
queryType: prometheus
relativeTimeRange:
from: 11m0s
to: 1m0s
threshold:
model:
conditions:
- evaluator:
params:
- 0
type: gt
datasource:
IsPrunable: false
access: ''
apiVersion: ''
basicAuth: false
basicAuthUser: ''
created: '0001-01-01T00:00:00Z'
database: ''
id: -100
isDefault: false
jsonData: {}
name: __expr__
readOnly: false
secureJsonData: {}
type: __expr__
uid: __expr__
updated: '0001-01-01T00:00:00Z'
url: ''
user: ''
withCredentials: false
expression: prometheus_math
intervalMs: 1000
maxDataPoints: 43200
refId: threshold
type: threshold
queryType: threshold
source: true
for: 15m0s
labels:
__converted_prometheus_rule__: 'true'
severity: warning
missingSeriesEvalsToResolve: 10
noDataState: Ok
title: AnthropicTokenBurst
trigger:
interval: 1m

View File

@@ -0,0 +1,66 @@
apiVersion: rules.alerting.grafana.app/v0alpha1
kind: AlertRule
metadata:
annotations:
grafana.app/folder: f6hsw6
grafana.com/provenance: ''
grafana.com/updateTimestamp: '2026-04-21T09:07:11Z'
grafana.com/updatedBy: ae5t47muqbegwf
labels:
grafana.app/folder: f6hsw6
grafana.com/group: zero-alerts-fast
grafana.com/group-index: '2'
name: afjn4kz1g4jk0a
namespace: stacks-890472
spec:
execErrState: Error
expressions:
A:
datasourceUID: grafanacloud-prom
model:
editorMode: code
expr: zero_replication_total_lag_millisecond{deployment_environment="production",
service_name="zero-rm"}
instant: true
intervalMs: 1000
legendFormat: __auto
maxDataPoints: 43200
range: false
refId: A
relativeTimeRange:
from: 10m0s
to: 0s
C:
model:
conditions:
- evaluator:
params:
- 1000
type: gt
operator:
type: and
query:
params:
- C
reducer:
params: []
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
queryType: expression
source: true
for: 5m0s
labels:
service: zero
severity: warning
noDataState: NoData
title: Zero replication lag elevated
trigger:
interval: 5m

View File

@@ -0,0 +1,81 @@
apiVersion: rules.alerting.grafana.app/v0alpha1
kind: AlertRule
metadata:
annotations:
grafana.app/folder: f6hsw6
grafana.com/provenance: ''
grafana.com/updateTimestamp: '2026-04-28T08:48:46Z'
grafana.com/updatedBy: ae5t47muqbegwf
labels:
grafana.app/folder: f6hsw6
grafana.com/group: zero-fly-alerts
grafana.com/group-index: '5'
name: bfjyilovht2pse
namespace: stacks-890472
spec:
annotations:
description: |-
Fires when the 5-minute rolling count of logs with detected_level error or fatal is greater than zero for service_name matching zero-(rm|vs) and deployment_environment=production.
Investigate in Grafana Explore (grafanacloud-tldraw-logs):
{service_name=~"zero-(rm|vs)", deployment_environment="production"} | detected_level=~"error|fatal"
Typical causes include failed upstream connections, auth/sync failures, or crashes; use log fields (component, worker, status, message) to narrow the blast radius.
summary: 'Production Zero: error/fatal logs in the last evaluation window.'
execErrState: Ok
expressions:
A:
datasourceUID: grafanacloud-logs
model:
datasource:
type: loki
uid: grafanacloud-logs
editorMode: code
expr:
sum(count_over_time({service_name=~"zero-(rm|vs)", deployment_environment="production"}
| detected_level=~"error|fatal" [5m]))
instant: true
intervalMs: 1000
maxDataPoints: 43200
queryType: instant
refId: A
queryType: instant
relativeTimeRange:
from: 10m0s
to: 0s
C:
model:
conditions:
- evaluator:
params:
- 0
type: gt
operator:
type: and
query:
params:
- C
reducer:
params: []
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
queryType: expression
source: true
for: 1m0s
labels:
service: zero
severity: critical
noDataState: Ok
paused: true
title: Zero error log (production)
trigger:
interval: 1m

View File

@@ -0,0 +1,72 @@
apiVersion: rules.alerting.grafana.app/v0alpha1
kind: AlertRule
metadata:
annotations:
grafana.app/folder: f6hsw6
grafana.com/provenance: ''
grafana.com/updateTimestamp: '2026-04-28T08:48:46Z'
grafana.com/updatedBy: ae5t47muqbegwf
labels:
grafana.app/folder: f6hsw6
grafana.com/group: zero-fly-alerts
grafana.com/group-index: '2'
name: cfjnb1whljcowe
namespace: stacks-890472
spec:
annotations:
summary: Zero container disk utilization exceeds 80%
execErrState: Error
expressions:
A:
datasourceUID: afj5re4yk9og0e
model:
datasource:
type: prometheus
uid: afj5re4yk9og0e
editorMode: code
expr: |-
(fly_instance_filesystem_blocks{app=~"production-zero-rm|production-zero-vs"} - fly_instance_filesystem_blocks_avail{app=~"production-zero-rm|production-zero-vs"}) * fly_instance_filesystem_block_size{app=~"production-zero-rm|production-zero-vs"} /
(fly_instance_filesystem_blocks{app=~"production-zero-rm|production-zero-vs"} * fly_instance_filesystem_block_size{app=~"production-zero-rm|production-zero-vs"})
instant: true
intervalMs: 1000
legendFormat: __auto
maxDataPoints: 43200
range: false
refId: A
relativeTimeRange:
from: 10m0s
to: 0s
C:
model:
conditions:
- evaluator:
params:
- 0.8
type: gt
operator:
type: and
query:
params:
- C
reducer:
params: []
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
queryType: expression
source: true
for: 10m0s
labels:
service: zero
severity: critical
noDataState: NoData
title: Flyio zero container disk high
trigger:
interval: 1m

View File

@@ -0,0 +1,72 @@
apiVersion: rules.alerting.grafana.app/v0alpha1
kind: AlertRule
metadata:
annotations:
grafana.app/folder: f6hsw6
grafana.com/provenance: ''
grafana.com/updateTimestamp: '2026-04-28T08:48:46Z'
grafana.com/updatedBy: ae5t47muqbegwf
labels:
grafana.app/folder: f6hsw6
grafana.com/group: zero-fly-alerts
grafana.com/group-index: '3'
name: cfjnbehzmlhxcc
namespace: stacks-890472
spec:
annotations:
summary: Zero container CPU utilization exceeds 85%
execErrState: Error
expressions:
A:
datasourceUID: afj5re4yk9og0e
model:
datasource:
type: prometheus
uid: afj5re4yk9og0e
editorMode: code
expr:
sum by (instance, app)(rate(fly_instance_cpu{app=~"production-zero-rm|production-zero-vs",mode!="idle"}[5m]))
/ sum by (instance, app)(rate(fly_instance_cpu{app=~"production-zero-rm|production-zero-vs"}[5m]))
instant: true
intervalMs: 1000
legendFormat: __auto
maxDataPoints: 43200
range: false
refId: A
relativeTimeRange:
from: 10m0s
to: 0s
C:
model:
conditions:
- evaluator:
params:
- 0.85
type: gt
operator:
type: and
query:
params:
- C
reducer:
params: []
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
queryType: expression
source: true
for: 15m0s
labels:
service: zero
severity: warning
noDataState: NoData
title: Flyio zero container cpu high
trigger:
interval: 1m

View File

@@ -0,0 +1,80 @@
apiVersion: rules.alerting.grafana.app/v0alpha1
kind: AlertRule
metadata:
annotations:
grafana.app/folder: f6hsw6
grafana.com/provenance: ''
grafana.com/updateTimestamp: '2026-04-28T08:48:46Z'
grafana.com/updatedBy: ae5t47muqbegwf
labels:
grafana.app/folder: f6hsw6
grafana.com/group: zero-fly-alerts
grafana.com/group-index: '6'
name: cfjyl9tbrury8b
namespace: stacks-890472
spec:
annotations:
description: |-
Fires when the 5-minute rolling count of logs with detected_level error or fatal is greater than zero for service_name matching zero-(rm|vs) and deployment_environment=staging.
Investigate in Grafana Explore (grafanacloud-tldraw-logs):
{service_name=~"zero-(rm|vs)", deployment_environment="staging"} | detected_level=~"error|fatal"
Typical causes include failed upstream connections, auth/sync failures, or crashes; use log fields (component, worker, status, message) to narrow the blast radius.
summary: 'Staging Zero: error/fatal logs in the last evaluation window.'
execErrState: Ok
expressions:
A:
datasourceUID: grafanacloud-logs
model:
datasource:
type: loki
uid: grafanacloud-logs
editorMode: code
expr: sum(count_over_time({service_name=~"zero-(rm|vs)", deployment_environment="staging"}
| detected_level=~"error|fatal" [5m]))
instant: true
intervalMs: 1000
maxDataPoints: 43200
queryType: instant
refId: A
queryType: instant
relativeTimeRange:
from: 10m0s
to: 0s
C:
model:
conditions:
- evaluator:
params:
- 0
type: gt
operator:
type: and
query:
params:
- C
reducer:
params: []
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
queryType: expression
source: true
for: 5m0s
labels:
service: zero
severity: warning
noDataState: Ok
paused: true
title: Zero error log (staging)
trigger:
interval: 1m

View File

@@ -0,0 +1,69 @@
apiVersion: rules.alerting.grafana.app/v0alpha1
kind: AlertRule
metadata:
annotations:
grafana.app/folder: f6hsw6
grafana.com/provenance: ''
grafana.com/updateTimestamp: '2026-04-21T09:06:29Z'
grafana.com/updatedBy: ae5t47muqbegwf
labels:
grafana.app/folder: f6hsw6
grafana.com/group: zero-alerts
grafana.com/group-index: '1'
name: dfjn3qe3l7w8wc
namespace: stacks-890472
spec:
execErrState: Error
expressions:
A:
datasourceUID: grafanacloud-prom
model:
editorMode: code
expr: zero_replica_backup_lag_millisecond{deployment_environment="production",
service_name="zero-rm"}
instant: true
intervalMs: 1000
legendFormat: __auto
maxDataPoints: 43200
range: false
refId: A
relativeTimeRange:
from: 10m0s
to: 0s
C:
model:
conditions:
- evaluator:
params:
- 1200000
type: gt
operator:
type: and
query:
params:
- C
reducer:
params: []
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
queryType: expression
source: true
for: 20m0s
labels:
service: zero
severity: critical
noDataState: NoData
notificationSettings:
receiver: Discord
type: SimplifiedRouting
title: Zero replica backup lag
trigger:
interval: 20m

View File

@@ -0,0 +1,72 @@
apiVersion: rules.alerting.grafana.app/v0alpha1
kind: AlertRule
metadata:
annotations:
grafana.app/folder: f6hsw6
grafana.com/provenance: ''
grafana.com/updateTimestamp: '2026-04-28T08:48:46Z'
grafana.com/updatedBy: ae5t47muqbegwf
labels:
grafana.app/folder: f6hsw6
grafana.com/group: zero-fly-alerts
grafana.com/group-index: '1'
name: dfjn9xellhibka
namespace: stacks-890472
spec:
annotations:
summary: Zero container memory utilization exceeds 90%
execErrState: Error
expressions:
A:
datasourceUID: afj5re4yk9og0e
model:
datasource:
type: prometheus
uid: afj5re4yk9og0e
editorMode: code
expr: 1 - (fly_instance_memory_mem_available{app=~"production-zero-rm|production-zero-vs"}
/ fly_instance_memory_mem_total{app=~"production-zero-rm|production-zero-vs"})
instant: true
intervalMs: 1000
legendFormat: __auto
maxDataPoints: 43200
range: false
refId: A
relativeTimeRange:
from: 10m0s
to: 0s
C:
model:
conditions:
- evaluator:
params:
- 0.9
type: gt
operator:
type: and
query:
params:
- C
reducer:
params: []
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
queryType: expression
source: true
for: 1m0s
keepFiringFor: 5m0s
labels:
service: zero
severity: warning
noDataState: NoData
title: Flyio zero container memory high
trigger:
interval: 1m

View File

@@ -0,0 +1,94 @@
apiVersion: rules.alerting.grafana.app/v0alpha1
kind: AlertRule
metadata:
annotations:
grafana.app/folder: dotcom
grafana.com/provenance: ''
labels:
grafana.app/folder: dotcom
grafana.com/group: dotcom-alerts
grafana.com/group-index: '1'
name: dotcom-file-do-exceptions
namespace: stacks-890472
spec:
annotations:
description: TLFileDurableObject threw more than 300 uncaught exceptions in the
last hour. Quiet hours run 1-50; the bursts that followed hibernation being
enabled on 2026-08-13 run 700-1300. Check the "DO exceptions by namespace"
panel on the Cloudflare platform metrics dashboard, then Sentry for the
matching source tag.
summary: File room durable objects are throwing uncaught exceptions
execErrState: Error
expressions:
A:
datasourceUID: ffuyblsuemj28b
model:
columns:
- selector: sum.requests
text: exceptions
type: number
format: table
global_query_id: ''
parser: backend
refId: A
root_selector: data.viewer.accounts[0].durableObjectsInvocationsAdaptiveGroups
source: url
type: graphql
url: https://api.cloudflare.com/client/v4/graphql
url_options:
body_content_type: application/json
body_graphql_query: |-
query {
viewer {
accounts(filter: {accountTag: "c34edc4e76350954b63adebde86d5eb1"}) {
durableObjectsInvocationsAdaptiveGroups(limit: 1, filter: {datetime_geq: "${__from:date:iso}", datetime_leq: "${__to:date:iso}", namespaceId: "5864db4344ac4c55bfd94e81dd25a043", status: "scriptThrewException"}) {
sum { requests }
}
}
}
}
body_type: graphql
method: POST
relativeTimeRange:
from: 1h0m0s
to: 0s
C:
model:
conditions:
- evaluator:
params:
- 300
type: gt
operator:
type: and
query:
params:
- C
reducer:
params: []
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
queryType: expression
source: true
for: 10m0s
labels:
service: dotcom
severity: warning
# The query filters to scriptThrewException, so a healthy hour returns no rows at
# all rather than a zero. NoData here means "nothing threw" — alerting on it would
# invert the rule.
noDataState: Ok
notificationSettings:
receiver: Discord
type: SimplifiedRouting
title: File room DO exceptions
trigger:
interval: 10m

View File

@@ -0,0 +1,95 @@
apiVersion: rules.alerting.grafana.app/v0alpha1
kind: AlertRule
metadata:
annotations:
grafana.app/folder: dotcom
grafana.com/provenance: ''
labels:
grafana.app/folder: dotcom
grafana.com/group: version-chain-alerts
grafana.com/group-index: '1'
name: dotcom-version-chain-verify-failures
namespace: stacks-890472
spec:
annotations:
description: More than two retired version chains failed to reproduce the state
the durable object persisted, in the last hour. Check the "Verify results"
panel on the Dotcom events dashboard for the reason. head-mismatch is the
serious one — the delta encoding is producing a different board — while
missing and legacy-fallback mean the chain did not answer at all. Until the
legacy full copies are turned off these versions still read; after that they
are unreadable and there is no rebuild path, so this should block the
rollout advancing.
summary: Version chain reconstructions are not reproducing persisted state
execErrState: Error
expressions:
A:
datasourceUID: bdgu7tk03irr4e
model:
dateTimeType: DATETIME
editorMode: code
extrapolate: true
format: table
formatAs: table
intervalFactor: 1
# rawQuery must be the boolean, not a copy of the SQL. The dashboard panels
# carry the SQL in both fields, but that shape reaches the plugin as
# already-interpolated text and dies on "unsupported value type: $from".
# Matches 'fail' rather than negating 'ok': a check the durable object
# declined to run (outcome 'skipped') is not a failing chain, and on a
# board big enough to skip, a negated match would page on every retire.
query: |-
SELECT count() as fails
FROM 'MEASURE'
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
AND blob1 = 'version_chain_verify'
AND blob2 = 'production-tldraw-multiplayer'
AND blob3 = 'fail'
rawQuery: true
refId: A
round: 0s
skip_comments: true
step: ''
relativeTimeRange:
from: 1h0m0s
to: 0s
C:
model:
conditions:
- evaluator:
params:
- 2
type: gt
operator:
type: and
query:
params:
- C
reducer:
params: []
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
queryType: expression
source: true
for: 10m0s
labels:
service: dotcom
severity: warning
# count() with no GROUP BY returns a row holding 0 rather than no rows, so a
# healthy hour evaluates a real zero. NoData here means the Analytics Engine
# datasource itself is down, which is not a version chain problem.
noDataState: Ok
notificationSettings:
receiver: Discord
type: SimplifiedRouting
title: Version chain verify failures
trigger:
interval: 10m

View File

@@ -0,0 +1,85 @@
apiVersion: rules.alerting.grafana.app/v0alpha1
kind: AlertRule
metadata:
annotations:
grafana.app/folder: dotcom
grafana.com/provenance: ''
labels:
grafana.app/folder: dotcom
grafana.com/group: version-chain-alerts
grafana.com/group-index: '2'
name: dotcom-version-chain-write-errors
namespace: stacks-890472
spec:
annotations:
description: A chain write failed non-transiently in the last hour. These have
already retried three times and fallen back to writing a legacy full copy,
so no version is lost and boards are unaffected — but the chain is unhealthy
and the rollout should not advance while this fires. Check the "Chain write
errors" panel on the Dotcom events dashboard, then Sentry for the underlying
exception. Expected rate is zero.
summary: Version chain writes are failing and falling back to legacy copies
execErrState: Error
expressions:
A:
datasourceUID: bdgu7tk03irr4e
model:
dateTimeType: DATETIME
editorMode: code
extrapolate: true
format: table
formatAs: table
intervalFactor: 1
# See the verify-failures rule: rawQuery is the boolean, and the SQL lives
# only in `query`, or $from reaches the plugin uninterpolated.
query: |-
SELECT count() as errors
FROM 'MEASURE'
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
AND blob1 = 'version_chain_error'
AND blob2 = 'production-tldraw-multiplayer'
rawQuery: true
refId: A
round: 0s
skip_comments: true
step: ''
relativeTimeRange:
from: 1h0m0s
to: 0s
C:
model:
conditions:
- evaluator:
params:
- 0
type: gt
operator:
type: and
query:
params:
- C
reducer:
params: []
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
queryType: expression
source: true
for: 10m0s
labels:
service: dotcom
severity: warning
noDataState: Ok
notificationSettings:
receiver: Discord
type: SimplifiedRouting
title: Version chain write errors
trigger:
interval: 10m

View File

@@ -0,0 +1,116 @@
apiVersion: rules.alerting.grafana.app/v0alpha1
kind: AlertRule
metadata:
annotations:
grafana.app/folder: anthropic
grafana.com/provenance: ''
grafana.com/updateTimestamp: '2026-03-20T11:22:55Z'
grafana.com/updatedBy: bf1wptf89bpq8d
labels:
grafana.app/folder: anthropic
grafana.com/group: anthropic
grafana.com/group-index: '0'
name: e75d2f6c-44cf-54bf-8a74-ced2e045fcb6
namespace: stacks-890472
spec:
annotations:
description: 'The last completed day cost ${{ $value | humanize }}, more than
1.5x the day before. The cost metric updates once a day at about 03:00 UTC,
so this describes yesterday.'
summary: Anthropic API daily cost spike detected.
execErrState: Ok
expressions:
prometheus_math:
model:
datasource:
IsPrunable: false
access: ''
apiVersion: ''
basicAuth: false
basicAuthUser: ''
created: '0001-01-01T00:00:00Z'
database: ''
id: -100
isDefault: false
jsonData: {}
name: __expr__
readOnly: false
secureJsonData: {}
type: __expr__
uid: __expr__
updated: '0001-01-01T00:00:00Z'
url: ''
user: ''
withCredentials: false
expression: is_number($query) || is_nan($query) || is_inf($query)
intervalMs: 1000
maxDataPoints: 43200
refId: prometheus_math
type: math
queryType: math
query:
datasourceUID: grafanacloud-prom
model:
datasource:
type: prometheus
uid: grafanacloud-prom
expr: |
(
sum(increase(gen_ai_cost{job=~"integrations/anthropic/.*"}[1d])) / 100
>
1.5 * sum(increase(gen_ai_cost{job=~"integrations/anthropic/.*"}[1d] offset 1d)) / 100
)
and
sum(increase(gen_ai_cost{job=~"integrations/anthropic/.*"}[1d])) / 100 > 50
instant: true
intervalMs: 1000
maxDataPoints: 43200
range: false
refId: query
queryType: prometheus
relativeTimeRange:
from: 11m0s
to: 1m0s
threshold:
model:
conditions:
- evaluator:
params:
- 0
type: gt
datasource:
IsPrunable: false
access: ''
apiVersion: ''
basicAuth: false
basicAuthUser: ''
created: '0001-01-01T00:00:00Z'
database: ''
id: -100
isDefault: false
jsonData: {}
name: __expr__
readOnly: false
secureJsonData: {}
type: __expr__
uid: __expr__
updated: '0001-01-01T00:00:00Z'
url: ''
user: ''
withCredentials: false
expression: prometheus_math
intervalMs: 1000
maxDataPoints: 43200
refId: threshold
type: threshold
queryType: threshold
source: true
for: 5m0s
labels:
__converted_prometheus_rule__: 'true'
severity: warning
missingSeriesEvalsToResolve: 10
noDataState: Ok
title: AnthropicDailyCostSpike
trigger:
interval: 1m

View File

@@ -0,0 +1,110 @@
apiVersion: rules.alerting.grafana.app/v0alpha1
kind: AlertRule
metadata:
annotations:
grafana.app/folder: anthropic
grafana.com/provenance: ''
grafana.com/updateTimestamp: '2026-03-20T11:22:55Z'
grafana.com/updatedBy: bf1wptf89bpq8d
labels:
grafana.app/folder: anthropic
grafana.com/group: anthropic
grafana.com/group-index: '2'
name: eec2ad9e-30b9-5c8f-802f-53e26ab8fdc9
namespace: stacks-890472
spec:
annotations:
description: 'The last completed day cost ${{ $value | humanize }}, above the
$1000 threshold. The cost metric updates once a day at about 03:00 UTC, so
this describes yesterday.'
summary: Anthropic API cost threshold exceeded.
execErrState: Ok
expressions:
prometheus_math:
model:
datasource:
IsPrunable: false
access: ''
apiVersion: ''
basicAuth: false
basicAuthUser: ''
created: '0001-01-01T00:00:00Z'
database: ''
id: -100
isDefault: false
jsonData: {}
name: __expr__
readOnly: false
secureJsonData: {}
type: __expr__
uid: __expr__
updated: '0001-01-01T00:00:00Z'
url: ''
user: ''
withCredentials: false
expression: is_number($query) || is_nan($query) || is_inf($query)
intervalMs: 1000
maxDataPoints: 43200
refId: prometheus_math
type: math
queryType: math
query:
datasourceUID: grafanacloud-prom
model:
datasource:
type: prometheus
uid: grafanacloud-prom
expr: |
sum(increase(gen_ai_cost{job=~"integrations/anthropic/.*"}[1d])) / 100 > 1000
instant: true
intervalMs: 1000
maxDataPoints: 43200
range: false
refId: query
queryType: prometheus
relativeTimeRange:
from: 11m0s
to: 1m0s
threshold:
model:
conditions:
- evaluator:
params:
- 0
type: gt
datasource:
IsPrunable: false
access: ''
apiVersion: ''
basicAuth: false
basicAuthUser: ''
created: '0001-01-01T00:00:00Z'
database: ''
id: -100
isDefault: false
jsonData: {}
name: __expr__
readOnly: false
secureJsonData: {}
type: __expr__
uid: __expr__
updated: '0001-01-01T00:00:00Z'
url: ''
user: ''
withCredentials: false
expression: prometheus_math
intervalMs: 1000
maxDataPoints: 43200
refId: threshold
type: threshold
queryType: threshold
source: true
for: 15m0s
labels:
__converted_prometheus_rule__: 'true'
severity: critical
missingSeriesEvalsToResolve: 10
noDataState: Ok
title: AnthropicHighCostThreshold
trigger:
interval: 1m

View File

@@ -0,0 +1,66 @@
apiVersion: rules.alerting.grafana.app/v0alpha1
kind: AlertRule
metadata:
annotations:
grafana.app/folder: f6hsw6
grafana.com/provenance: ''
grafana.com/updateTimestamp: '2026-04-21T09:07:11Z'
grafana.com/updatedBy: ae5t47muqbegwf
labels:
grafana.app/folder: f6hsw6
grafana.com/group: zero-alerts-fast
grafana.com/group-index: '1'
name: efjn4hgdlhc00f
namespace: stacks-890472
spec:
execErrState: Error
expressions:
A:
datasourceUID: grafanacloud-prom
model:
editorMode: code
expr: zero_replication_total_lag_millisecond{deployment_environment="production",
service_name="zero-rm"}
instant: true
intervalMs: 1000
legendFormat: __auto
maxDataPoints: 43200
range: false
refId: A
relativeTimeRange:
from: 10m0s
to: 0s
C:
model:
conditions:
- evaluator:
params:
- 2000
type: gt
operator:
type: and
query:
params:
- C
reducer:
params: []
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
queryType: expression
source: true
for: 5m0s
labels:
service: zero
severity: critical
noDataState: NoData
title: Zero replication lag high
trigger:
interval: 5m

View File

@@ -0,0 +1,69 @@
apiVersion: rules.alerting.grafana.app/v0alpha1
kind: AlertRule
metadata:
annotations:
grafana.app/folder: f6hsw6
grafana.com/provenance: ''
grafana.com/updateTimestamp: '2026-04-28T08:48:46Z'
grafana.com/updatedBy: ae5t47muqbegwf
labels:
grafana.app/folder: f6hsw6
grafana.com/group: zero-fly-alerts
grafana.com/group-index: '4'
name: efjnbq9h4iha8a
namespace: stacks-890472
spec:
annotations:
summary: Zero container OOM kill detected
execErrState: Ok
expressions:
A:
datasourceUID: afj5re4yk9og0e
model:
datasource:
type: prometheus
uid: afj5re4yk9og0e
editorMode: code
expr: fly_instance_exit_oom{app=~"production-zero-rm|production-zero-vs"}
instant: true
intervalMs: 1000
legendFormat: __auto
maxDataPoints: 43200
range: false
refId: A
relativeTimeRange:
from: 10m0s
to: 0s
C:
model:
conditions:
- evaluator:
params:
- 0
type: gt
operator:
type: and
query:
params:
- C
reducer:
params: []
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
queryType: expression
source: true
labels:
service: zero
severity: critical
noDataState: Ok
title: Flyio zero container oom kill
trigger:
interval: 1m

View File

@@ -0,0 +1,254 @@
apiVersion: dashboard.grafana.app/v0alpha1
kind: Dashboard
metadata:
annotations:
grafana.app/folder: dotcom
grafana.app/saved-from-ui: Grafana Cloud
labels: {}
name: adgumkhou3chsd
namespace: stacks-890472
spec:
annotations:
list:
- builtIn: 1
datasource:
type: grafana
uid: -- Grafana --
enable: true
hide: true
iconColor: rgba(0, 211, 255, 1)
name: Annotations & Alerts
type: dashboard
editable: true
fiscalYearStartMonth: 0
graphTooltip: 0
liveNow: false
panels:
- datasource:
type: vertamedia-clickhouse-datasource
uid: bdgu7tk03irr4e
fieldConfig:
defaults:
color:
mode: palette-classic
custom:
axisBorderShow: false
axisCenteredZero: false
axisColorMode: text
axisLabel: ''
axisPlacement: auto
barAlignment: 0
barWidthFactor: 0.6
drawStyle: line
fillOpacity: 0
gradientMode: none
hideFrom:
legend: false
tooltip: false
viz: false
insertNulls: false
lineInterpolation: linear
lineWidth: 1
pointSize: 5
scaleDistribution:
type: linear
showPoints: auto
showValues: false
spanNulls: false
stacking:
group: A
mode: none
thresholdsStyle:
mode: 'off'
thresholds:
mode: absolute
steps:
- color: green
value: 0
- color: red
value: 80
gridPos:
h: 21
w: 24
x: 0
y: 0
id: 1
options:
annotations:
clustering: -1
multiLane: false
legend:
calcs: []
displayMode: list
placement: bottom
showLegend: true
tooltip:
hideZeros: false
maxHeight: 600
mode: single
sort: none
pluginVersion: 13.1.0-24556818486
targets:
- datasource:
type: vertamedia-clickhouse-datasource
uid: bdgu7tk03irr4e
dateTimeType: DATETIME
editorMode: builder
extrapolate: true
format: time_series
formatAs: time_series
formattedQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval' SECOND)\
\ as date, blob1 as event, count() as total\n\tFROM 'MEASURE'\n WHERE NOT\
\ endsWith(event, 'tldraw-multiplayer')\n\tAND timestamp >= toDateTime($from)\
\ AND timestamp <= toDateTime($to)\n\tGROUP BY date, event\n\tORDER BY date\
\ ASC"
intervalFactor: 1
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval' SECOND) as\
\ date, blob1 as event, count() as total\n\tFROM 'MEASURE'\n\tWHERE timestamp\
\ >= toDateTime($from) AND timestamp <= toDateTime($to)\n\tAND blob2 = '${environment}-tldraw-multiplayer'\n\
\tGROUP BY date, event\n\tORDER BY date ASC"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120' SECOND) as date,\
\ blob1 as event, count() as total\n\tFROM 'MEASURE'\n\tWHERE timestamp >=\
\ toDateTime(1733213050) AND timestamp <= toDateTime(1733385850)\n\tAND blob2\
\ = 'pr-5028-tldraw-multiplayer'\n\tGROUP BY date, event\n\tORDER BY date ASC"
refId: A
round: 0s
showFormattedSQL: true
showHelp: true
skip_comments: true
step: ''
title: Room events
type: timeseries
- datasource:
type: vertamedia-clickhouse-datasource
uid: bdgu7tk03irr4e
fieldConfig:
defaults:
color:
mode: palette-classic
custom:
axisBorderShow: false
axisCenteredZero: false
axisColorMode: text
axisLabel: ''
axisPlacement: right
barAlignment: 0
barWidthFactor: 0.6
drawStyle: line
fillOpacity: 0
gradientMode: none
hideFrom:
legend: false
tooltip: false
viz: false
insertNulls: 3600000
lineInterpolation: stepAfter
lineStyle:
fill: solid
lineWidth: 1
pointSize: 6
scaleDistribution:
type: linear
showPoints: auto
showValues: false
spanNulls: false
stacking:
group: A
mode: none
thresholdsStyle:
mode: 'off'
min: 0
thresholds:
mode: absolute
steps:
- color: green
value: 0
- color: red
value: 80
gridPos:
h: 8
w: 12
x: 0
y: 21
id: 13
interval: 5m
options:
annotations:
clustering: -1
multiLane: false
legend:
calcs: []
displayMode: table
placement: bottom
showLegend: false
tooltip:
hideZeros: false
mode: single
sort: none
pluginVersion: 13.1.0-24556818486
targets:
- datasource:
type: vertamedia-clickhouse-datasource
uid: bdgu7tk03irr4e
dateTimeType: DATETIME
editorMode: builder
extrapolate: true
format: time_series
formatAs: time_series
formattedQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval' SECOND)\
\ as date, blob1 as name, max(double1) as avg_attempts\n\tFROM 'MEASURE'\n\
\tWHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)\n \
\ AND name = 'persist_success'\n\tGROUP BY date, name\n\tORDER BY date ASC"
intervalFactor: 1
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval' SECOND) as\
\ date, blob1 as name, max(double1) as avg_attempts\n\tFROM 'MEASURE'\n\t\
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)\n \
\ AND name = 'persist_success'\n\tGROUP BY date, name\n\tORDER BY date ASC"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '300' SECOND) as date,\
\ blob1 as name, max(double1) as avg_attempts\n\tFROM 'MEASURE'\n\tWHERE timestamp\
\ >= toDateTime(1762169567) AND timestamp <= toDateTime(1762180367)\n AND\
\ name = 'persist_success'\n\tGROUP BY date, name\n\tORDER BY date ASC"
refId: A
round: 0s
showFormattedSQL: false
showHelp: false
skip_comments: true
step: ''
title: max attempts before persistence success
type: timeseries
preload: false
refresh: 5s
schemaVersion: 42
templating:
list:
- current:
text: production
value: production
description: Environment to filter by. Use production, staging, or your pr number
(pr-42).
hide: 0
label: 'Environment: production | staging | pr-42'
name: environment
query: production
skipUrlSync: false
type: textbox
time:
from: now-3h
to: now
timepicker:
refresh_intervals:
- 5s
- 10s
- 30s
- 1m
- 5m
- 15m
- 30m
- 1h
- 2h
- 1d
timezone: browser
title: Dotcom events V1
tags:
- provisioned
- repo:tldraw/tldraw

View File

@@ -0,0 +1,195 @@
apiVersion: dashboard.grafana.app/v0alpha1
kind: Dashboard
metadata:
annotations:
grafana.app/folder: anthropic
grafana.app/message: creating dashboard from the Cloud Connections plugin
labels: {}
name: anthropic-integration-scrape-overview
namespace: stacks-890472
spec:
editable: false
fiscalYearStartMonth: 0
graphTooltip: 0
id: null
links:
- asDropdown: false
icon: external link
includeVars: true
keepTime: true
tags:
- anthropic-integration
targetBlank: true
title: Dashboards
type: dashboards
liveNow: false
panels:
- gridPos:
h: 12
w: 7
x: 0
y: 0
id: 1
options:
code:
language: plaintext
showLineNumbers: false
showMiniMap: false
content: <center><img style="width:auto;max-height:320px;" src="https://storage.googleapis.com/grafanalabs-integration-logos/anthropic-logo-dark.svg"></center>
mode: markdown
pluginVersion: 9.4.3
title: Logo
type: text
- datasource:
type: prometheus
uid: $datasource
description: Shows the number of samples received by metric name received for
this integration.
fieldConfig:
defaults:
color:
mode: palette-classic
custom:
axisCenteredZero: false
axisColorMode: text
axisLabel: ''
axisPlacement: auto
barAlignment: 0
drawStyle: line
fillOpacity: 0
gradientMode: none
hideFrom:
legend: false
tooltip: false
viz: false
lineInterpolation: linear
lineStyle:
fill: solid
lineWidth: 1
pointSize: 5
scaleDistribution:
type: linear
showPoints: auto
spanNulls: false
stacking:
group: A
mode: none
thresholdsStyle:
mode: 'off'
mappings: []
thresholds:
mode: absolute
steps:
- color: green
value: null
- color: red
value: 80
unit: pps
overrides: []
gridPos:
h: 12
w: 17
x: 7
y: 0
id: 7
options:
legend:
calcs: []
displayMode: list
placement: bottom
showLegend: true
tooltip:
mode: single
sort: none
targets:
- datasource:
type: prometheus
uid: $datasource
editorMode: code
expr: count by (__name__) ({__name__=~".+", job=~"integrations/anthropic/.+",
scrape_job=~"$scrape_job"})
legendFormat: __auto
range: true
refId: A
title: Samples Received
type: timeseries
refresh: 30s
revision: 1
schemaVersion: 38
style: dark
tags:
- anthropic-integration
- provisioned
- repo:tldraw/tldraw
templating:
list:
- current:
selected: false
text: default
value: default
hide: 0
includeAll: false
label: Data source
multi: false
name: datasource
options: []
query: prometheus
queryValue: ''
refresh: 1
regex: (?!grafanacloud-usage|grafanacloud-ml-metrics).+
skipUrlSync: false
type: datasource
- allValue: .+
current:
text: All
value:
- $__all
datasource: $datasource
definition: label_values({__name__=~".+", job=~"integrations/anthropic/.+"},
scrape_job)
hide: 0
includeAll: true
label: scrape_job
multi: true
name: scrape_job
options: []
query: label_values({__name__=~".+", job=~"integrations/anthropic/.+"}, scrape_job)
refresh: 2
regex: ''
skipUrlSync: false
sort: 0
tagValuesQuery: ''
tags: []
tagsQuery: ''
type: query
useTags: false
- allValue: integrations/anthropic/.+
current:
text: All
value:
- $__all
datasource: $datasource
definition: label_values({job=~"integrations/anthropic/.+"}, job)
hide: 0
includeAll: true
label: job
multi: true
name: job
options: []
query: label_values({job=~"integrations/anthropic/.+"}, job)
refresh: 2
regex: ''
skipUrlSync: false
sort: 0
tagValuesQuery: ''
tags: []
tagsQuery: ''
type: query
useTags: false
time:
from: now-30m
to: now
timepicker: {}
timezone: ''
title: Anthropic scrape overview
weekStart: ''

View File

@@ -0,0 +1,375 @@
apiVersion: dashboard.grafana.app/v0alpha1
kind: Dashboard
metadata:
annotations:
grafana.app/folder: dotcom
labels: {}
name: edrigjn435v5sf
namespace: stacks-890472
spec:
annotations:
list:
- builtIn: 1
datasource:
type: grafana
uid: -- Grafana --
enable: true
hide: true
iconColor: rgba(0, 211, 255, 1)
name: Annotations & Alerts
type: dashboard
editable: true
fiscalYearStartMonth: 0
graphTooltip: 0
links: []
panels:
- datasource:
type: vertamedia-clickhouse-datasource
uid: bdgu7tk03irr4e
fieldConfig:
defaults:
color:
mode: thresholds
mappings: []
thresholds:
mode: absolute
steps:
- color: green
value: null
- color: red
value: 80
overrides: []
gridPos:
h: 8
w: 12
x: 0
y: 0
id: 3
options:
minVizHeight: 75
minVizWidth: 75
orientation: auto
reduceOptions:
calcs:
- sum
fields: ''
values: false
showThresholdLabels: false
showThresholdMarkers: true
sizing: auto
pluginVersion: 11.3.0-77222
targets:
- datasource:
type: vertamedia-clickhouse-datasource
uid: bdgu7tk03irr4e
dateTimeType: DATETIME
editorMode: builder
extrapolate: true
format: time_series
formatAs: time_series
formattedQuery: "SELECT count(*)\n\tFROM 'BEMO_ANALYICS'\n WHERE blob1 =\
\ 'connect'\n\tAND timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)\n\
\tGROUP BY date\n\tORDER BY date ASC"
intervalFactor: 1
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval' MINUTE) as\
\ date, count() as numConnects\n\tFROM 'BEMO_ANALYTICS'\n WHERE blob1 =\
\ 'connect'\n\tAND timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)\n\
\tGROUP BY date\n\tORDER BY date ASC"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '1800' MINUTE) as date,\
\ count() as numConnects\n\tFROM 'BEMO_ANALYTICS'\n WHERE blob1 = 'connect'\n\
\tAND timestamp >= toDateTime(1722620600) AND timestamp <= toDateTime(1725212600)\n\
\tGROUP BY date\n\tORDER BY date ASC"
refId: A
round: 0s
showFormattedSQL: false
showHelp: false
skip_comments: true
step: ''
title: Num Connects
type: gauge
- datasource:
type: vertamedia-clickhouse-datasource
uid: bdgu7tk03irr4e
fieldConfig:
defaults:
color:
mode: palette-classic
custom:
axisBorderShow: false
axisCenteredZero: false
axisColorMode: text
axisLabel: ''
axisPlacement: auto
barAlignment: 0
barWidthFactor: 0.6
drawStyle: line
fillOpacity: 0
gradientMode: none
hideFrom:
legend: false
tooltip: false
viz: false
insertNulls: false
lineInterpolation: linear
lineWidth: 1
pointSize: 5
scaleDistribution:
type: linear
showPoints: auto
spanNulls: false
stacking:
group: A
mode: none
thresholdsStyle:
mode: 'off'
mappings: []
thresholds:
mode: absolute
steps:
- color: green
value: null
- color: red
value: 80
overrides: []
gridPos:
h: 8
w: 12
x: 12
y: 0
id: 1
options:
legend:
calcs: []
displayMode: list
placement: bottom
showLegend: true
tooltip:
mode: single
sort: none
pluginVersion: 11.3.0-77222
targets:
- datasource:
type: vertamedia-clickhouse-datasource
uid: bdgu7tk03irr4e
dateTimeType: DATETIME
editorMode: builder
extrapolate: true
format: time_series
formatAs: time_series
formattedQuery: "SELECT count(*)\n\tFROM 'BEMO_ANALYICS'\n WHERE blob1 =\
\ 'connect'\n\tAND timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)\n\
\tGROUP BY date\n\tORDER BY date ASC"
intervalFactor: 1
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval' MINUTE) as\
\ date, count() as numConnects\n\tFROM 'BEMO_ANALYTICS'\n WHERE blob1 =\
\ 'connect'\n\tAND timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)\n\
\tGROUP BY date\n\tORDER BY date ASC"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '3600' MINUTE) as date,\
\ count() as numConnects\n\tFROM 'BEMO_ANALYTICS'\n WHERE blob1 = 'connect'\n\
\tAND timestamp >= toDateTime(1720773373) AND timestamp <= toDateTime(1726292377)\n\
\tGROUP BY date\n\tORDER BY date ASC"
refId: A
round: 0s
showFormattedSQL: false
showHelp: false
skip_comments: true
step: ''
title: Num Connects
type: timeseries
- datasource:
uid: bdgu7tk03irr4e
fieldConfig:
defaults:
color:
mode: palette-classic
custom:
axisBorderShow: false
axisCenteredZero: false
axisColorMode: text
axisLabel: ''
axisPlacement: auto
barAlignment: 0
barWidthFactor: 0.6
drawStyle: line
fillOpacity: 0
gradientMode: none
hideFrom:
legend: false
tooltip: false
viz: false
insertNulls: false
lineInterpolation: linear
lineWidth: 1
pointSize: 5
scaleDistribution:
type: linear
showPoints: auto
spanNulls: false
stacking:
group: A
mode: none
thresholdsStyle:
mode: 'off'
mappings: []
thresholds:
mode: absolute
steps:
- color: green
value: null
- color: red
value: 80
overrides: []
gridPos:
h: 8
w: 12
x: 0
y: 8
id: 5
options:
legend:
calcs: []
displayMode: list
placement: bottom
showLegend: true
tooltip:
mode: single
sort: none
pluginVersion: 11.3.0-77222
targets:
- dateTimeType: DATETIME
editorMode: builder
extrapolate: true
format: time_series
formattedQuery: SELECT $timeSeries as t, count() FROM $table WHERE $timeFilter
GROUP BY t ORDER BY t
intervalFactor: 1
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval' MINUTE) as\
\ date, count() as numConnects,\n\tif(\n\t\tstartsWith(blob2, 'http://localhost')\
\ or startsWith(blob2, 'http://127.0.0.1'),\n\t\t'localhost',\n\t\tif(\n\t\
\t\tendsWith(blob2, '.csb.app'),\n\t\t\t'code sandbox',\n\t\t\tblob2\n\t\t\
)\n\t) as host\n\tFROM 'BEMO_ANALYTICS'\n WHERE blob1 = 'connect'\n\tAND\
\ timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)\n\t-- AND\
\ NOT(startsWith(host, 'http://localhost')) AND NOT(startsWith(host, 'http://127.0.0.1'))\n\
\tGROUP BY date, host\n ORDER BY date ASC"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '30' MINUTE) as date,\
\ count() as numConnects,\n\tif(\n\t\tstartsWith(blob2, 'http://localhost')\
\ or startsWith(blob2, 'http://127.0.0.1'),\n\t\t'localhost',\n\t\tif(\n\t\
\t\tendsWith(blob2, '.csb.app'),\n\t\t\t'code sandbox',\n\t\t\tblob2\n\t\t\
)\n\t) as host\n\tFROM 'BEMO_ANALYTICS'\n WHERE blob1 = 'connect'\n\tAND\
\ timestamp >= toDateTime(1728620743) AND timestamp <= toDateTime(1728642343)\n\
\t\n\tGROUP BY date, host\n ORDER BY date ASC"
refId: A
round: 0s
skip_comments: true
title: Num Connects
type: timeseries
- datasource:
type: vertamedia-clickhouse-datasource
uid: bdgu7tk03irr4e
fieldConfig:
defaults:
color:
mode: palette-classic
custom:
axisBorderShow: false
axisCenteredZero: false
axisColorMode: text
axisLabel: ''
axisPlacement: auto
barAlignment: 0
barWidthFactor: 0.6
drawStyle: line
fillOpacity: 0
gradientMode: none
hideFrom:
legend: false
tooltip: false
viz: false
insertNulls: false
lineInterpolation: smooth
lineWidth: 1
pointSize: 5
scaleDistribution:
type: linear
showPoints: auto
spanNulls: false
stacking:
group: A
mode: none
thresholdsStyle:
mode: 'off'
mappings: []
thresholds:
mode: absolute
steps:
- color: green
value: null
- color: red
value: 80
overrides: []
gridPos:
h: 19
w: 12
x: 12
y: 8
id: 4
options:
legend:
calcs: []
displayMode: table
placement: bottom
showLegend: true
tooltip:
mode: single
sort: none
pluginVersion: 11.3.0-77222
targets:
- datasource:
type: vertamedia-clickhouse-datasource
uid: bdgu7tk03irr4e
dateTimeType: DATETIME
editorMode: builder
extrapolate: true
format: time_series
formatAs: time_series
formattedQuery: "SELECT count() as numConnects, blob2\n\tFROM 'BEMO_ANALYTICS'\n\
\ WHERE blob1 = 'connect'\n\tAND timestamp >= toDateTime($from) AND timestamp\
\ <= toDateTime($to)\n\tGROUP BY blob2\n ORDER BY numConnects ASC"
intervalFactor: 1
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval' MINUTE) as\
\ date, count() as numConnects,\n\tif(\n\t\tstartsWith(blob2, 'http://localhost')\
\ or startsWith(blob2, 'http://127.0.0.1'),\n\t\t'localhost',\n\t\tif(\n\t\
\t\tendsWith(blob2, '.csb.app'),\n\t\t\t'code sandbox',\n\t\t\tblob2\n\t\t\
)\n\t) as host\n\tFROM 'BEMO_ANALYTICS'\n WHERE blob1 = 'connect'\n\tAND\
\ timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)\n\t-- AND\
\ NOT(startsWith(host, 'http://localhost')) AND NOT(startsWith(host, 'http://127.0.0.1'))\n\
\tGROUP BY date, host\n ORDER BY date ASC"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '3600' MINUTE) as date,\
\ count() as numConnects,\n\tif(\n\t\tstartsWith(blob2, 'http://localhost')\
\ or startsWith(blob2, 'http://127.0.0.1'),\n\t\t'localhost',\n\t\tif(\n\t\
\t\tendsWith(blob2, '.csb.app'),\n\t\t\t'code sandbox',\n\t\t\tblob2\n\t\t\
)\n\t) as host\n\tFROM 'BEMO_ANALYTICS'\n WHERE blob1 = 'connect'\n\tAND\
\ timestamp >= toDateTime(1720773373) AND timestamp <= toDateTime(1726292377)\n\
\t\n\tGROUP BY date, host\n ORDER BY date ASC"
refId: A
round: 0s
showFormattedSQL: false
showHelp: false
skip_comments: true
step: ''
title: Panel Title
type: timeseries
preload: false
schemaVersion: 40
tags:
- provisioned
- repo:tldraw/tldraw
templating:
list: []
time:
from: now-6h
to: now
timepicker: {}
timezone: browser
title: Bemo analytics
weekStart: ''

View File

@@ -0,0 +1,605 @@
apiVersion: dashboard.grafana.app/v0alpha1
kind: Dashboard
metadata:
annotations:
grafana.app/message: Add qtype split, env toggle, deploy annotations and restart
panel
grafana.app/folder: f6hsw6
labels: {}
name: slow-queries
namespace: stacks-890472
spec:
annotations:
list:
- datasource:
type: loki
uid: grafanacloud-logs
enable: true
expr: '{service_name=~"$service", deployment_environment="$env"} |= "OpenTelemetry
SDK started" | process_worker="reaper"'
hide: false
iconColor: red
name: Deploys (machine restarts)
target:
expr: '{service_name=~"$service", deployment_environment="$env"} |= "OpenTelemetry
SDK started" | process_worker="reaper"'
queryType: range
refId: Anno
textFormat: '{{host_name}}'
titleFormat: zero-cache restart
editable: true
graphTooltip: 1
panels:
- collapsed: false
gridPos:
h: 1
w: 24
x: 0
y: 0
id: 1
panels: []
title: Overview
type: row
- datasource:
type: loki
uid: grafanacloud-logs
description: Total "Slow SQLite query" log lines from zero-cache in the selected
time range.
fieldConfig:
defaults:
unit: short
overrides: []
gridPos:
h: 5
w: 4
x: 0
y: 1
id: 2
options:
colorMode: value
graphMode: none
reduceOptions:
calcs:
- lastNotNull
targets:
- datasource:
type: loki
uid: grafanacloud-logs
editorMode: code
expr: sum(count_over_time({service_name=~"$service", deployment_environment="$env"}
|~ "Slow SQLite query" | type=~"$qtype" [$__range]))
instant: true
queryType: instant
range: false
refId: A
title: Slow queries in range
type: stat
- datasource:
type: loki
uid: grafanacloud-logs
description: Longest slow query seen in the selected time range.
fieldConfig:
defaults:
unit: ms
overrides: []
gridPos:
h: 5
w: 4
x: 4
y: 1
id: 3
options:
colorMode: value
graphMode: none
reduceOptions:
calcs:
- lastNotNull
targets:
- datasource:
type: loki
uid: grafanacloud-logs
editorMode: code
expr: max(max_over_time({service_name=~"$service", deployment_environment="$env"}
|~ "Slow SQLite query" | type=~"$qtype" | regexp "Slow SQLite query (?P<duration_ms>[0-9.]+)"
| unwrap duration_ms [$__range]))
instant: true
queryType: instant
range: false
refId: A
title: Max duration in range
type: stat
- datasource:
type: loki
uid: grafanacloud-logs
description: Slow SQLite query log lines per interval, by Zero service (zero-vs
= view-syncer, zero-rm = replication-manager).
fieldConfig:
defaults:
custom:
fillOpacity: 8
lineWidth: 1
showPoints: never
unit: short
overrides: []
gridPos:
h: 10
w: 8
x: 8
y: 1
id: 4
options:
legend:
displayMode: list
placement: bottom
showLegend: true
tooltip:
mode: multi
sort: desc
targets:
- datasource:
type: loki
uid: grafanacloud-logs
editorMode: code
expr:
sum by (service_name) (count_over_time({service_name=~"$service", deployment_environment="$env"}
|~ "Slow SQLite query" | type=~"$qtype" [$__auto]))
legendFormat: '{{service_name}}'
queryType: range
refId: A
title: Slow query rate
type: timeseries
- datasource:
type: loki
uid: grafanacloud-logs
description: Duration parsed from the log line, in ms. Only queries over zero-cache's
slow threshold are logged, so these are percentiles of the slow tail, not of
all queries.
fieldConfig:
defaults:
custom:
fillOpacity: 8
lineWidth: 1
showPoints: never
unit: ms
overrides: []
gridPos:
h: 10
w: 8
x: 16
y: 1
id: 5
options:
legend:
displayMode: list
placement: bottom
showLegend: true
tooltip:
mode: multi
sort: desc
targets:
- datasource:
type: loki
uid: grafanacloud-logs
editorMode: code
expr: quantile_over_time(0.5, {service_name=~"$service", deployment_environment="$env"}
|~ "Slow SQLite query" | type=~"$qtype" | regexp "Slow SQLite query (?P<duration_ms>[0-9.]+)"
| unwrap duration_ms [$__auto]) by ()
legendFormat: p50
queryType: range
refId: A
- datasource:
type: loki
uid: grafanacloud-logs
editorMode: code
expr: quantile_over_time(0.95, {service_name=~"$service", deployment_environment="$env"}
|~ "Slow SQLite query" | type=~"$qtype" | regexp "Slow SQLite query (?P<duration_ms>[0-9.]+)"
| unwrap duration_ms [$__auto]) by ()
legendFormat: p95
queryType: range
refId: B
- datasource:
type: loki
uid: grafanacloud-logs
editorMode: code
expr: quantile_over_time(0.99, {service_name=~"$service", deployment_environment="$env"}
|~ "Slow SQLite query" | type=~"$qtype" | regexp "Slow SQLite query (?P<duration_ms>[0-9.]+)"
| unwrap duration_ms [$__auto]) by ()
legendFormat: p99
queryType: range
refId: C
title: Slow query duration percentiles
type: timeseries
- datasource:
type: loki
uid: grafanacloud-logs
description: Which Fly machines are producing slow queries.
fieldConfig:
defaults:
custom:
fillOpacity: 8
lineWidth: 1
showPoints: never
unit: short
overrides: []
gridPos:
h: 5
w: 8
x: 0
y: 6
id: 6
options:
legend:
displayMode: list
placement: bottom
showLegend: true
tooltip:
mode: multi
sort: desc
targets:
- datasource:
type: loki
uid: grafanacloud-logs
editorMode: code
expr:
sum by (host_name) (count_over_time({service_name=~"$service", deployment_environment="$env"}
|~ "Slow SQLite query" | type=~"$qtype" [$__auto]))
legendFormat: '{{host_name}}'
queryType: range
refId: A
title: Slow queries by worker host
type: timeseries
- datasource:
type: loki
uid: grafanacloud-logs
description: Worker restarts (every zero-cache process logs one line per worker
on boot; a fleet-wide burst = a deploy) and post-restart auth failures from
the custom-query transform endpoint. Slow-query storms almost always line up
with these.
fieldConfig:
defaults:
custom:
drawStyle: bars
fillOpacity: 15
lineWidth: 1
showPoints: never
unit: short
overrides: []
gridPos:
h: 5
w: 16
x: 8
y: 11
id: 15
options:
legend:
displayMode: list
placement: bottom
showLegend: true
tooltip:
mode: multi
sort: desc
targets:
- datasource:
type: loki
uid: grafanacloud-logs
editorMode: code
expr: sum(count_over_time({service_name=~"$service", deployment_environment="$env"}
|= "OpenTelemetry SDK started" [$__auto]))
legendFormat: worker restarts
queryType: range
refId: A
- datasource:
type: loki
uid: grafanacloud-logs
editorMode: code
expr: sum(count_over_time({service_name=~"$service", deployment_environment="$env"}
|= "Connection auth validation failed" [$__auto]))
legendFormat: auth 401s on reconnect
queryType: range
refId: B
title: Deploys and reconnect storms
type: timeseries
- collapsed: false
gridPos:
h: 1
w: 24
x: 0
y: 16
id: 7
panels: []
title: By statement
type: row
- datasource:
type: loki
uid: grafanacloud-logs
description: The sql field is attached as structured metadata on each slow query
log line.
fieldConfig:
defaults:
custom:
align: auto
filterable: false
inspect: true
unit: short
overrides: []
gridPos:
h: 11
w: 12
x: 0
y: 17
id: 8
options:
showHeader: true
sortBy:
- desc: true
displayName: slow queries
targets:
- datasource:
type: loki
uid: grafanacloud-logs
editorMode: code
expr:
topk(15, sum by (sql) (count_over_time({service_name=~"$service", deployment_environment="$env"}
|~ "Slow SQLite query" | type=~"$qtype" [$__range])))
instant: true
queryType: instant
range: false
refId: A
title: Top statements by slow query count
transformations:
- id: organize
options:
excludeByName:
Time: true
renameByName:
Value: slow queries
'Value #A': slow queries
type: table
- datasource:
type: loki
uid: grafanacloud-logs
description: p95 of parsed duration, grouped by statement.
fieldConfig:
defaults:
custom:
align: auto
filterable: false
inspect: true
unit: ms
overrides: []
gridPos:
h: 11
w: 12
x: 12
y: 17
id: 9
options:
showHeader: true
sortBy:
- desc: true
displayName: p95 ms
targets:
- datasource:
type: loki
uid: grafanacloud-logs
editorMode: code
expr:
topk(15, quantile_over_time(0.95, {service_name=~"$service", deployment_environment="$env"}
|~ "Slow SQLite query" | type=~"$qtype" | regexp "Slow SQLite query (?P<duration_ms>[0-9.]+)"
| unwrap duration_ms [$__range]) by (sql))
instant: true
queryType: instant
range: false
refId: A
title: Top statements by p95 duration (ms)
transformations:
- id: organize
options:
excludeByName:
Time: true
renameByName:
Value: p95 ms
'Value #A': p95 ms
type: table
- collapsed: false
gridPos:
h: 1
w: 24
x: 0
y: 28
id: 10
panels: []
title: By table
type: row
- datasource:
type: loki
uid: grafanacloud-logs
description: "Table name extracted from the sql structured-metadata field. (Per-client
breakdowns are not possible here: more than 500 distinct clientGroupID values
per query window exceeds Loki's series limit.)"
fieldConfig:
defaults:
custom:
fillOpacity: 8
lineWidth: 1
showPoints: never
stacking:
group: A
mode: normal
unit: short
overrides: []
gridPos:
h: 9
w: 12
x: 0
y: 29
id: 11
options:
legend:
displayMode: list
placement: bottom
showLegend: true
tooltip:
mode: multi
sort: desc
targets:
- datasource:
type: loki
uid: grafanacloud-logs
editorMode: code
expr:
sum by (tbl) (count_over_time({service_name=~"$service", deployment_environment="$env"}
|~ "Slow SQLite query" | type=~"$qtype" | label_format tbl=`{{ regexReplaceAll
"(?s).*FROM .([a-zA-Z0-9._]+).*" .sql "$1" }}` [$__auto]))
legendFormat: '{{tbl}}'
queryType: range
refId: A
title: Slow queries by table
type: timeseries
- datasource:
type: loki
uid: grafanacloud-logs
description: zero-cache tags each statement event with class/method/type (e.g.
Statement/iterate/total).
fieldConfig:
defaults:
custom:
fillOpacity: 8
lineWidth: 1
showPoints: never
unit: short
overrides: []
gridPos:
h: 9
w: 12
x: 12
y: 29
id: 12
options:
legend:
displayMode: list
placement: bottom
showLegend: true
tooltip:
mode: multi
sort: desc
targets:
- datasource:
type: loki
uid: grafanacloud-logs
editorMode: code
expr:
sum by (method, type) (count_over_time({service_name=~"$service", deployment_environment="$env"}
|~ "Slow SQLite query" | type=~"$qtype" [$__auto]))
legendFormat: '{{method}}/{{type}}'
queryType: range
refId: A
title: Slow queries by method / type
type: timeseries
- collapsed: false
gridPos:
h: 1
w: 24
x: 0
y: 38
id: 13
panels: []
title: Raw logs
type: row
- datasource:
type: loki
uid: grafanacloud-logs
gridPos:
h: 12
w: 24
x: 0
y: 39
id: 14
options:
dedupStrategy: none
enableLogDetails: true
showTime: true
sortOrder: Descending
wrapLogMessage: true
targets:
- datasource:
type: loki
uid: grafanacloud-logs
editorMode: code
expr: '{service_name=~"$service", deployment_environment="$env"} |~ "Slow SQLite
query" | type=~"$qtype"'
maxLines: 200
queryType: range
refId: A
title: Recent slow query logs
type: logs
refresh: ''
schemaVersion: 42
tags:
- zero
- loki
- slow-queries
- provisioned
- repo:tldraw/tldraw
templating:
list:
- allValue: zero-.+
current:
text:
- All
value:
- $__all
hide: 0
includeAll: true
label: Zero service
multi: true
name: service
options: []
query: zero-vs,zero-rm
type: custom
- current:
text: production
value: production
hide: 0
includeAll: false
label: Environment
multi: false
name: env
options:
- selected: true
text: production
value: production
- selected: false
text: staging
value: staging
query: production,staging
type: custom
- allValue: .*
current:
text: All
value: $__all
description: total = wall-clock across row iteration (includes syncer pipeline
stalls); sqlite = time inside the SQLite engine. Filter to sqlite to see whether
the database itself is slow.
hide: 0
includeAll: true
label: Query type
multi: false
name: qtype
options:
- selected: true
text: All
value: $__all
- selected: false
text: total
value: total
- selected: false
text: sqlite
value: sqlite
query: total,sqlite
type: custom
time:
from: now-6h
to: now
timezone: browser
title: Zero slow queries

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,819 @@
apiVersion: dashboard.grafana.app/v2
kind: Dashboard
metadata:
annotations:
grafana.app/folder: dotcom
grafana.app/saved-from-ui: Grafana Cloud
labels: {}
name: mix4slb
namespace: stacks-890472
spec:
annotations:
- kind: AnnotationQuery
spec:
builtIn: true
enable: true
hide: true
iconColor: rgba(0, 211, 255, 1)
name: Annotations & Alerts
query:
datasource:
name: -- Grafana --
group: grafana
kind: DataQuery
spec: {}
version: v0
cursorSync: 'Off'
editable: true
elements:
panel-1:
kind: Panel
spec:
data:
kind: QueryGroup
spec:
queries:
- kind: PanelQuery
spec:
hidden: false
query:
datasource:
name: bdgu7tk03irr4e
group: vertamedia-clickhouse-datasource
kind: DataQuery
spec:
dateTimeType: DATETIME
editorMode: builder
extrapolate: true
format: time_series
formatAs: time_series
formattedQuery: "SELECT toStartOfInterval(timestamp, INTERVAL\
\ '$interval' SECOND) as date, quantileWeighted(0.5)(double1,\
\ _sample_interval) as p50_ms, quantileWeighted(0.95)(double1,\
\ _sample_interval) as p95_ms, quantileWeighted(0.99)(double1,\
\ _sample_interval) as p99_ms\n\tFROM 'MEASURE'\n\tWHERE timestamp\
\ >= toDateTime($from) AND timestamp <= toDateTime($to)\n\t\
AND blob2 = '${environment}-tldraw-multiplayer'\n\tAND blob1\
\ = 'outbox_effect'\n\tGROUP BY date\n\tORDER BY date ASC"
intervalFactor: 1
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
\ SECOND) as date, quantileWeighted(0.5)(double1, _sample_interval)\
\ as p50_ms, quantileWeighted(0.95)(double1, _sample_interval)\
\ as p95_ms, quantileWeighted(0.99)(double1, _sample_interval)\
\ as p99_ms\n\tFROM 'MEASURE'\n\tWHERE timestamp >= toDateTime($from)\
\ AND timestamp <= toDateTime($to)\n\tAND blob2 = '${environment}-tldraw-multiplayer'\n\
\tAND blob1 = 'outbox_effect'\n\tGROUP BY date\n\tORDER BY date\
\ ASC"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
\ SECOND) as date, quantileWeighted(0.5)(double1, _sample_interval)\
\ as p50_ms, quantileWeighted(0.95)(double1, _sample_interval)\
\ as p95_ms, quantileWeighted(0.99)(double1, _sample_interval)\
\ as p99_ms\n\tFROM 'MEASURE'\n\tWHERE timestamp >= toDateTime(1762170000)\
\ AND timestamp <= toDateTime(1762180000)\n\tAND blob2 = 'production-tldraw-multiplayer'\n\
\tAND blob1 = 'outbox_effect'\n\tGROUP BY date\n\tORDER BY date\
\ ASC"
round: 0s
showFormattedSQL: true
skip_comments: true
step: ''
version: v0
refId: A
queryOptions: {}
transformations: []
description: 'End-to-end: time from a file change committing to its effect
completing (room notification / publish). double1 = ageMs.'
id: 1
links: []
title: Outbox effect latency (p50 / p95 / p99)
vizConfig:
group: timeseries
kind: VizConfig
spec:
fieldConfig:
defaults:
color:
mode: palette-classic
custom:
axisBorderShow: false
axisCenteredZero: false
axisColorMode: text
axisLabel: ''
axisPlacement: right
barAlignment: 0
barWidthFactor: 0.6
drawStyle: line
fillOpacity: 0
gradientMode: none
hideFrom:
legend: false
tooltip: false
viz: false
insertNulls: false
lineInterpolation: linear
lineWidth: 1
pointSize: 5
scaleDistribution:
type: linear
showPoints: auto
showValues: false
spanNulls: false
stacking:
group: A
mode: none
thresholdsStyle:
mode: 'off'
min: 0
thresholds:
mode: absolute
steps:
- color: green
value: 0
- color: red
value: 80
unit: ms
overrides: []
options:
annotations:
clustering: -1
multiLane: false
legend:
calcs: []
displayMode: list
placement: bottom
showLegend: true
tooltip:
hideZeros: false
mode: single
sort: none
version: 13.2.0-30616302309
panel-2:
kind: Panel
spec:
data:
kind: QueryGroup
spec:
queries:
- kind: PanelQuery
spec:
hidden: false
query:
datasource:
name: bdgu7tk03irr4e
group: vertamedia-clickhouse-datasource
kind: DataQuery
spec:
dateTimeType: DATETIME
editorMode: builder
extrapolate: true
format: time_series
formatAs: time_series
formattedQuery: "SELECT toStartOfInterval(timestamp, INTERVAL\
\ '$interval' SECOND) as date, sum(double1) as processed, sum(double2)\
\ as failed\n\tFROM 'MEASURE'\n\tWHERE timestamp >= toDateTime($from)\
\ AND timestamp <= toDateTime($to)\n\tAND blob2 = '${environment}-tldraw-multiplayer'\n\
\tAND blob1 = 'outbox_drain'\n\tGROUP BY date\n\tORDER BY date\
\ ASC"
intervalFactor: 1
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
\ SECOND) as date, sum(double1) as processed, sum(double2) as\
\ failed\n\tFROM 'MEASURE'\n\tWHERE timestamp >= toDateTime($from)\
\ AND timestamp <= toDateTime($to)\n\tAND blob2 = '${environment}-tldraw-multiplayer'\n\
\tAND blob1 = 'outbox_drain'\n\tGROUP BY date\n\tORDER BY date\
\ ASC"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
\ SECOND) as date, sum(double1) as processed, sum(double2) as\
\ failed\n\tFROM 'MEASURE'\n\tWHERE timestamp >= toDateTime(1762170000)\
\ AND timestamp <= toDateTime(1762180000)\n\tAND blob2 = 'production-tldraw-multiplayer'\n\
\tAND blob1 = 'outbox_drain'\n\tGROUP BY date\n\tORDER BY date\
\ ASC"
round: 0s
showFormattedSQL: true
skip_comments: true
step: ''
version: v0
refId: A
queryOptions: {}
transformations: []
description: Per-drain summary. double1 = rows processed, double2 = rows failed
this drain.
id: 2
links: []
title: Outbox throughput (processed / failed)
vizConfig:
group: timeseries
kind: VizConfig
spec:
fieldConfig:
defaults:
color:
mode: palette-classic
custom:
axisBorderShow: false
axisCenteredZero: false
axisColorMode: text
axisLabel: ''
axisPlacement: right
barAlignment: 0
barWidthFactor: 0.6
drawStyle: line
fillOpacity: 0
gradientMode: none
hideFrom:
legend: false
tooltip: false
viz: false
insertNulls: false
lineInterpolation: linear
lineWidth: 1
pointSize: 5
scaleDistribution:
type: linear
showPoints: auto
showValues: false
spanNulls: false
stacking:
group: A
mode: none
thresholdsStyle:
mode: 'off'
min: 0
thresholds:
mode: absolute
steps:
- color: green
value: 0
- color: red
value: 80
unit: short
overrides: []
options:
annotations:
clustering: -1
multiLane: false
legend:
calcs: []
displayMode: list
placement: bottom
showLegend: true
tooltip:
hideZeros: false
mode: single
sort: none
version: 13.2.0-30616302309
panel-3:
kind: Panel
spec:
data:
kind: QueryGroup
spec:
queries:
- kind: PanelQuery
spec:
hidden: false
query:
datasource:
name: bdgu7tk03irr4e
group: vertamedia-clickhouse-datasource
kind: DataQuery
spec:
dateTimeType: DATETIME
editorMode: builder
extrapolate: true
format: time_series
formatAs: time_series
formattedQuery: "SELECT toStartOfInterval(timestamp, INTERVAL\
\ '$interval' SECOND) as date, blob4 as command, quantileWeighted(0.95)(double1,\
\ _sample_interval) as p95_ms\n\tFROM 'MEASURE'\n\tWHERE timestamp\
\ >= toDateTime($from) AND timestamp <= toDateTime($to)\n\t\
AND blob2 = '${environment}-tldraw-multiplayer'\n\tAND blob1\
\ = 'outbox_effect'\n\tGROUP BY date, command\n\tORDER BY date\
\ ASC"
intervalFactor: 1
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
\ SECOND) as date, blob4 as command, quantileWeighted(0.95)(double1,\
\ _sample_interval) as p95_ms\n\tFROM 'MEASURE'\n\tWHERE timestamp\
\ >= toDateTime($from) AND timestamp <= toDateTime($to)\n\t\
AND blob2 = '${environment}-tldraw-multiplayer'\n\tAND blob1\
\ = 'outbox_effect'\n\tGROUP BY date, command\n\tORDER BY date\
\ ASC"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
\ SECOND) as date, blob4 as command, quantileWeighted(0.95)(double1,\
\ _sample_interval) as p95_ms\n\tFROM 'MEASURE'\n\tWHERE timestamp\
\ >= toDateTime(1762170000) AND timestamp <= toDateTime(1762180000)\n\
\tAND blob2 = 'production-tldraw-multiplayer'\n\tAND blob1 =\
\ 'outbox_effect'\n\tGROUP BY date, command\n\tORDER BY date\
\ ASC"
round: 0s
showFormattedSQL: true
skip_comments: true
step: ''
version: v0
refId: A
queryOptions: {}
transformations: []
description: publish/unpublish ride the update command (awaitPersist) and
are the slowest. blob3=table, blob4=command.
id: 3
links: []
title: Outbox effect latency p95 by command
vizConfig:
group: timeseries
kind: VizConfig
spec:
fieldConfig:
defaults:
color:
mode: palette-classic
custom:
axisBorderShow: false
axisCenteredZero: false
axisColorMode: text
axisLabel: ''
axisPlacement: right
barAlignment: 0
barWidthFactor: 0.6
drawStyle: line
fillOpacity: 0
gradientMode: none
hideFrom:
legend: false
tooltip: false
viz: false
insertNulls: false
lineInterpolation: linear
lineWidth: 1
pointSize: 5
scaleDistribution:
type: linear
showPoints: auto
showValues: false
spanNulls: false
stacking:
group: A
mode: none
thresholdsStyle:
mode: 'off'
min: 0
thresholds:
mode: absolute
steps:
- color: green
value: 0
- color: red
value: 80
unit: ms
overrides: []
options:
annotations:
clustering: -1
multiLane: false
legend:
calcs: []
displayMode: list
placement: bottom
showLegend: true
tooltip:
hideZeros: false
mode: single
sort: none
version: 13.2.0-30616302309
panel-4:
kind: Panel
spec:
data:
kind: QueryGroup
spec:
queries:
- kind: PanelQuery
spec:
hidden: false
query:
datasource:
name: bdgu7tk03irr4e
group: vertamedia-clickhouse-datasource
kind: DataQuery
spec:
dateTimeType: DATETIME
editorMode: builder
extrapolate: true
format: time_series
formatAs: time_series
formattedQuery: "SELECT toStartOfInterval(timestamp, INTERVAL\
\ '$interval' SECOND) as date, quantileWeighted(0.95)(double3,\
\ _sample_interval) as drain_p95_ms\n\tFROM 'MEASURE'\n\tWHERE\
\ timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)\n\
\tAND blob2 = '${environment}-tldraw-multiplayer'\n\tAND blob1\
\ = 'outbox_drain'\n\tGROUP BY date\n\tORDER BY date ASC"
intervalFactor: 1
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
\ SECOND) as date, quantileWeighted(0.95)(double3, _sample_interval)\
\ as drain_p95_ms\n\tFROM 'MEASURE'\n\tWHERE timestamp >= toDateTime($from)\
\ AND timestamp <= toDateTime($to)\n\tAND blob2 = '${environment}-tldraw-multiplayer'\n\
\tAND blob1 = 'outbox_drain'\n\tGROUP BY date\n\tORDER BY date\
\ ASC"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
\ SECOND) as date, quantileWeighted(0.95)(double3, _sample_interval)\
\ as drain_p95_ms\n\tFROM 'MEASURE'\n\tWHERE timestamp >= toDateTime(1762170000)\
\ AND timestamp <= toDateTime(1762180000)\n\tAND blob2 = 'production-tldraw-multiplayer'\n\
\tAND blob1 = 'outbox_drain'\n\tGROUP BY date\n\tORDER BY date\
\ ASC"
round: 0s
showFormattedSQL: true
skip_comments: true
step: ''
version: v0
refId: A
queryOptions: {}
transformations: []
description: double3 = drainMs. Rising with flat throughput = individual effects
getting slower.
id: 4
links: []
title: Outbox drain duration p95
vizConfig:
group: timeseries
kind: VizConfig
spec:
fieldConfig:
defaults:
color:
mode: palette-classic
custom:
axisBorderShow: false
axisCenteredZero: false
axisColorMode: text
axisLabel: ''
axisPlacement: right
barAlignment: 0
barWidthFactor: 0.6
drawStyle: line
fillOpacity: 0
gradientMode: none
hideFrom:
legend: false
tooltip: false
viz: false
insertNulls: false
lineInterpolation: linear
lineWidth: 1
pointSize: 5
scaleDistribution:
type: linear
showPoints: auto
showValues: false
spanNulls: false
stacking:
group: A
mode: none
thresholdsStyle:
mode: 'off'
min: 0
thresholds:
mode: absolute
steps:
- color: green
value: 0
- color: red
value: 80
unit: ms
overrides: []
options:
annotations:
clustering: -1
multiLane: false
legend:
calcs: []
displayMode: list
placement: bottom
showLegend: true
tooltip:
hideZeros: false
mode: single
sort: none
version: 13.2.0-30616302309
panel-5:
kind: Panel
spec:
data:
kind: QueryGroup
spec:
queries:
- kind: PanelQuery
spec:
hidden: false
query:
datasource:
name: bdgu7tk03irr4e
group: vertamedia-clickhouse-datasource
kind: DataQuery
spec:
dateTimeType: DATETIME
editorMode: builder
extrapolate: true
format: time_series
formatAs: time_series
formattedQuery: "SELECT toStartOfInterval(timestamp, INTERVAL\
\ '$interval' SECOND) as date, blob4 as command, count() as\
\ total\n\tFROM 'MEASURE'\n\tWHERE timestamp >= toDateTime($from)\
\ AND timestamp <= toDateTime($to)\n\tAND blob2 = '${environment}-tldraw-multiplayer'\n\
\tAND blob1 = 'outbox_effect'\n\tGROUP BY date, command\n\t\
ORDER BY date ASC"
intervalFactor: 1
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
\ SECOND) as date, blob4 as command, count() as total\n\tFROM\
\ 'MEASURE'\n\tWHERE timestamp >= toDateTime($from) AND timestamp\
\ <= toDateTime($to)\n\tAND blob2 = '${environment}-tldraw-multiplayer'\n\
\tAND blob1 = 'outbox_effect'\n\tGROUP BY date, command\n\t\
ORDER BY date ASC"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
\ SECOND) as date, blob4 as command, count() as total\n\tFROM\
\ 'MEASURE'\n\tWHERE timestamp >= toDateTime(1762170000) AND\
\ timestamp <= toDateTime(1762180000)\n\tAND blob2 = 'production-tldraw-multiplayer'\n\
\tAND blob1 = 'outbox_effect'\n\tGROUP BY date, command\n\t\
ORDER BY date ASC"
round: 0s
showFormattedSQL: true
skip_comments: true
step: ''
version: v0
refId: A
queryOptions: {}
transformations: []
description: Volume of each effect type (insert / update / delete).
id: 5
links: []
title: Outbox effects by command
vizConfig:
group: timeseries
kind: VizConfig
spec:
fieldConfig:
defaults:
color:
mode: palette-classic
custom:
axisBorderShow: false
axisCenteredZero: false
axisColorMode: text
axisLabel: ''
axisPlacement: right
barAlignment: 0
barWidthFactor: 0.6
drawStyle: line
fillOpacity: 0
gradientMode: none
hideFrom:
legend: false
tooltip: false
viz: false
insertNulls: false
lineInterpolation: linear
lineWidth: 1
pointSize: 5
scaleDistribution:
type: linear
showPoints: auto
showValues: false
spanNulls: false
stacking:
group: A
mode: none
thresholdsStyle:
mode: 'off'
min: 0
thresholds:
mode: absolute
steps:
- color: green
value: 0
- color: red
value: 80
unit: short
overrides: []
options:
annotations:
clustering: -1
multiLane: false
legend:
calcs: []
displayMode: list
placement: bottom
showLegend: true
tooltip:
hideZeros: false
mode: single
sort: none
version: 13.2.0-30616302309
panel-6:
kind: Panel
spec:
data:
kind: QueryGroup
spec:
queries:
- kind: PanelQuery
spec:
hidden: false
query:
datasource:
name: bdgu7tk03irr4e
group: vertamedia-clickhouse-datasource
kind: DataQuery
spec:
dateTimeType: DATETIME
editorMode: builder
extrapolate: true
format: time_series
formatAs: time_series
formattedQuery: "SELECT toStartOfInterval(timestamp, INTERVAL\
\ '$interval' SECOND) as date, blob4 as command, count() as\
\ parked\n\tFROM 'MEASURE'\n\tWHERE timestamp >= toDateTime($from)\
\ AND timestamp <= toDateTime($to)\n\tAND blob2 = '${environment}-tldraw-multiplayer'\n\
\tAND blob1 = 'outbox_parked'\n\tGROUP BY date, command\n\t\
ORDER BY date ASC"
intervalFactor: 1
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
\ SECOND) as date, blob4 as command, count() as parked\n\tFROM\
\ 'MEASURE'\n\tWHERE timestamp >= toDateTime($from) AND timestamp\
\ <= toDateTime($to)\n\tAND blob2 = '${environment}-tldraw-multiplayer'\n\
\tAND blob1 = 'outbox_parked'\n\tGROUP BY date, command\n\t\
ORDER BY date ASC"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
\ SECOND) as date, blob4 as command, count() as parked\n\tFROM\
\ 'MEASURE'\n\tWHERE timestamp >= toDateTime(1762170000) AND\
\ timestamp <= toDateTime(1762180000)\n\tAND blob2 = 'production-tldraw-multiplayer'\n\
\tAND blob1 = 'outbox_parked'\n\tGROUP BY date, command\n\t\
ORDER BY date ASC"
round: 0s
showFormattedSQL: true
skip_comments: true
step: ''
version: v0
refId: A
queryOptions: {}
transformations: []
description: A parked effect (10 failed attempts) is a data-loss event and
also fires Sentry. Any line here = investigate.
id: 6
links: []
title: Outbox parked effects (should be 0)
vizConfig:
group: timeseries
kind: VizConfig
spec:
fieldConfig:
defaults:
color:
mode: palette-classic
custom:
axisBorderShow: false
axisCenteredZero: false
axisColorMode: text
axisLabel: ''
axisPlacement: right
barAlignment: 0
barWidthFactor: 0.6
drawStyle: line
fillOpacity: 0
gradientMode: none
hideFrom:
legend: false
tooltip: false
viz: false
insertNulls: false
lineInterpolation: linear
lineWidth: 1
pointSize: 5
scaleDistribution:
type: linear
showPoints: auto
showValues: false
spanNulls: false
stacking:
group: A
mode: none
thresholdsStyle:
mode: 'off'
min: 0
thresholds:
mode: absolute
steps:
- color: green
value: 0
- color: red
value: 80
unit: short
overrides: []
options:
annotations:
clustering: -1
multiLane: false
legend:
calcs: []
displayMode: list
placement: bottom
showLegend: true
tooltip:
hideZeros: false
mode: single
sort: none
version: 13.2.0-30616302309
layout:
kind: GridLayout
spec:
items:
- kind: GridLayoutItem
spec:
element:
kind: ElementReference
name: panel-1
height: 8
width: 12
x: 0
y: 0
- kind: GridLayoutItem
spec:
element:
kind: ElementReference
name: panel-2
height: 8
width: 12
x: 12
y: 0
- kind: GridLayoutItem
spec:
element:
kind: ElementReference
name: panel-3
height: 8
width: 12
x: 0
y: 8
- kind: GridLayoutItem
spec:
element:
kind: ElementReference
name: panel-4
height: 8
width: 12
x: 12
y: 8
- kind: GridLayoutItem
spec:
element:
kind: ElementReference
name: panel-5
height: 8
width: 12
x: 0
y: 16
- kind: GridLayoutItem
spec:
element:
kind: ElementReference
name: panel-6
height: 8
width: 12
x: 12
y: 16
links: []
liveNow: false
preload: false
tags:
- dotcom
- outbox
- sync-worker
- provisioned
- repo:tldraw/tldraw
timeSettings:
autoRefresh: ''
autoRefreshIntervals:
- 5s
- 10s
- 30s
- 1m
- 5m
- 15m
- 30m
- 1h
- 2h
- 1d
fiscalYearStartMonth: 0
from: now-6h
hideTimepicker: false
timezone: browser
to: now
title: File effect outbox
variables:
- kind: TextVariable
spec:
current:
text: production
value: production
description: Environment to filter by. Use production, staging, or your pr number
(pr-42).
hide: dontHide
label: 'Environment: production | staging | pr-42'
name: environment
query: production
skipUrlSync: false

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,940 @@
apiVersion: dashboard.grafana.app/v2beta1
kind: Dashboard
metadata:
annotations:
grafana.app/folder: dotcom
grafana.app/saved-from-ui: Grafana Cloud (steady)
labels: {}
name: kotx6wj
namespace: stacks-890472
spec:
annotations:
- kind: AnnotationQuery
spec:
builtIn: true
enable: true
hide: true
iconColor: rgba(0, 211, 255, 1)
name: Annotations & Alerts
query:
datasource:
name: -- Grafana --
group: grafana
kind: DataQuery
spec: {}
version: v0
cursorSync: 'Off'
description: How many sessions have been created with the MCP App
editable: true
elements:
panel-1:
kind: Panel
spec:
data:
kind: QueryGroup
spec:
queries:
- kind: PanelQuery
spec:
hidden: false
query:
datasource:
name: bdgu7tk03irr4e
group: vertamedia-clickhouse-datasource
kind: DataQuery
spec:
dateTimeType: DATETIME
editorMode: builder
extrapolate: true
format: time_series
formattedQuery: " SELECT toStartOfInterval(timestamp, INTERVAL\
\ '$interval' SECOND) as date, blob1 as event, count() as total\n\
\ FROM 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime($from)\
\ AND timestamp <= toDateTime($to)\n AND blob1 = 'session_start'\n\
\ GROUP BY date, event\n ORDER BY date ASC\n"
intervalFactor: 1
query: " SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
\ SECOND) as date, blob1 as event, count() as total\n FROM\
\ 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime($from) AND\
\ timestamp <= toDateTime($to)\n AND blob1 = 'session_start'\n\
\ GROUP BY date, event\n ORDER BY date ASC\n"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
\ SECOND) as date, blob1 as event, count() as total\n FROM\
\ 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime(1772623318)\
\ AND timestamp <= toDateTime(1772796118)\n AND blob1 = 'session_start'\n\
\ GROUP BY date, event\n ORDER BY date ASC"
round: 0s
skip_comments: true
version: v0
refId: A
queryOptions: {}
transformations: []
description: Sessions are created when you make a request to the MCP worker
id: 1
links: []
title: Sessions by Time
vizConfig:
group: timeseries
kind: VizConfig
spec:
fieldConfig:
defaults:
color:
mode: palette-classic
custom:
axisBorderShow: false
axisCenteredZero: false
axisColorMode: text
axisLabel: ''
axisPlacement: auto
barAlignment: 0
barWidthFactor: 0.6
drawStyle: line
fillOpacity: 0
gradientMode: none
hideFrom:
legend: false
tooltip: false
viz: false
insertNulls: false
lineInterpolation: linear
lineWidth: 1
pointSize: 5
scaleDistribution:
type: linear
showPoints: auto
showValues: false
spanNulls: false
stacking:
group: A
mode: none
thresholdsStyle:
mode: 'off'
thresholds:
mode: absolute
steps:
- color: green
value: 0
overrides: []
options:
legend:
calcs: []
displayMode: list
placement: bottom
showLegend: true
tooltip:
hideZeros: false
mode: single
sort: none
version: 13.0.0-22478626928
panel-4:
kind: Panel
spec:
data:
kind: QueryGroup
spec:
queries:
- kind: PanelQuery
spec:
hidden: false
query:
datasource:
name: bdgu7tk03irr4e
group: vertamedia-clickhouse-datasource
kind: DataQuery
spec:
dateTimeType: DATETIME
editorMode: builder
extrapolate: true
format: time_series
formattedQuery: " SELECT toStartOfInterval(timestamp, INTERVAL\
\ '$interval' SECOND) as date, blob2 as tool, count() as total\n\
\ FROM 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime($from)\
\ AND timestamp <= toDateTime($to)\n AND blob1 = 'tool_called'\n\
\ and blob2 = 'exec'\n GROUP BY date, tool\n ORDER\
\ BY date ASC\n"
intervalFactor: 1
query: " SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
\ SECOND) as date, blob2 as tool, count() as total\n FROM\
\ 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime($from)\
\ AND timestamp <= toDateTime($to)\n AND blob1 = 'tool_called'\n\
\ and blob2 = 'exec'\n GROUP BY date, tool\n ORDER\
\ BY date ASC\n"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
\ SECOND) as date, blob2 as tool, count() as total\n FROM\
\ 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime(1772623318)\
\ AND timestamp <= toDateTime(1772796118)\n AND blob1 = 'tool_called'\n\
\ and blob2 = 'exec'\n GROUP BY date, tool\n ORDER\
\ BY date ASC"
round: 0s
skip_comments: true
version: v0
refId: A
queryOptions: {}
transformations: []
description: tool_called events for the exec tool.
id: 4
links: []
title: Tool call (exec)
vizConfig:
group: stat
kind: VizConfig
spec:
fieldConfig:
defaults:
color:
mode: thresholds
thresholds:
mode: absolute
steps:
- color: blue
value: 0
overrides: []
options:
colorMode: value
graphMode: area
justifyMode: center
orientation: auto
percentChangeColorMode: standard
reduceOptions:
calcs:
- sum
fields: ''
values: false
showPercentChange: false
textMode: auto
wideLayout: true
version: 13.0.0-22478626928
panel-2:
kind: Panel
spec:
data:
kind: QueryGroup
spec:
queries:
- kind: PanelQuery
spec:
hidden: false
query:
datasource:
name: bdgu7tk03irr4e
group: vertamedia-clickhouse-datasource
kind: DataQuery
spec:
dateTimeType: DATETIME
editorMode: builder
extrapolate: true
format: time_series
formattedQuery: " SELECT toStartOfInterval(timestamp, INTERVAL\
\ '$interval' SECOND) as date, blob2 as tool, count() as total\n\
\ FROM 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime($from)\
\ AND timestamp <= toDateTime($to)\n AND blob1 = 'tool_called'\n\
\ and blob2 = 'search'\n GROUP BY date, tool\n ORDER\
\ BY date ASC\n"
intervalFactor: 1
query: " SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
\ SECOND) as date, blob2 as tool, count() as total\n FROM\
\ 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime($from)\
\ AND timestamp <= toDateTime($to)\n AND blob1 = 'tool_called'\n\
\ and blob2 = 'search'\n GROUP BY date, tool\n\
\ ORDER BY date ASC\n"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
\ SECOND) as date, blob2 as tool, count() as total\n FROM\
\ 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime(1772623318)\
\ AND timestamp <= toDateTime(1772796118)\n AND blob1 = 'tool_called'\n\
\ and blob2 = 'search'\n GROUP BY date, tool\n\
\ ORDER BY date ASC"
round: 0s
skip_comments: true
version: v0
refId: A
queryOptions: {}
transformations: []
description: tool_called events for the search tool.
id: 2
links: []
title: Tool call (search)
vizConfig:
group: stat
kind: VizConfig
spec:
fieldConfig:
defaults:
color:
mode: thresholds
thresholds:
mode: absolute
steps:
- color: green
value: 0
overrides: []
options:
colorMode: value
graphMode: area
justifyMode: center
orientation: auto
percentChangeColorMode: standard
reduceOptions:
calcs:
- sum
fields: ''
values: false
showPercentChange: false
textMode: auto
wideLayout: true
version: 13.0.0-22478626928
panel-6:
kind: Panel
spec:
data:
kind: QueryGroup
spec:
queries:
- kind: PanelQuery
spec:
hidden: false
query:
datasource:
name: bdgu7tk03irr4e
group: vertamedia-clickhouse-datasource
kind: DataQuery
spec:
dateTimeType: DATETIME
editorMode: builder
extrapolate: true
format: time_series
formattedQuery: " SELECT toStartOfInterval(timestamp, INTERVAL\
\ '$interval' SECOND) as date, blob1 as event, count() as total\n\
\ FROM 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime($from)\
\ AND timestamp <= toDateTime($to)\n AND blob1 = 'session_start'\n\
\ GROUP BY date, event\n ORDER BY date ASC\n"
intervalFactor: 1
query: " SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
\ SECOND) as date, blob1 as event, count() as total\n FROM\
\ 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime($from) AND\
\ timestamp <= toDateTime($to)\n AND blob1 = 'session_start'\n\
\ GROUP BY date, event\n ORDER BY date ASC\n"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
\ SECOND) as date, blob1 as event, count() as total\n FROM\
\ 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime(1772622707)\
\ AND timestamp <= toDateTime(1772795507)\n AND blob1 = 'session_start'\n\
\ GROUP BY date, event\n ORDER BY date ASC"
round: 0s
skip_comments: true
version: v0
refId: A
queryOptions: {}
transformations: []
description: Sessions are created when you make a request to the MCP worker
id: 6
links: []
title: Total Sessions
vizConfig:
group: stat
kind: VizConfig
spec:
fieldConfig:
defaults:
color:
mode: thresholds
thresholds:
mode: absolute
steps:
- color: green
value: 0
overrides: []
options:
colorMode: value
graphMode: area
justifyMode: auto
orientation: auto
percentChangeColorMode: standard
reduceOptions:
calcs:
- sum
fields: ''
values: false
showPercentChange: false
textMode: auto
wideLayout: true
version: 13.0.0-22478626928
panel-7:
kind: Panel
spec:
data:
kind: QueryGroup
spec:
queries:
- kind: PanelQuery
spec:
hidden: false
query:
datasource:
name: bdgu7tk03irr4e
group: vertamedia-clickhouse-datasource
kind: DataQuery
spec:
dateTimeType: DATETIME
editorMode: code
extrapolate: true
format: time_series
formatAs: time_series
intervalFactor: 1
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'
SECOND) as date, count() as sessions
FROM 'MCP_ANALYTICS'
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
AND blob1 = 'session_start'
GROUP BY date
ORDER BY date ASC"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'
SECOND) as date, count() as sessions
FROM 'MCP_ANALYTICS'
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
AND blob1 = 'session_start'
GROUP BY date
ORDER BY date ASC"
round: 0s
showFormattedSQL: false
showHelp: false
skip_comments: true
step: ''
version: v0
refId: A
queryOptions: {}
transformations: []
description: session_start events from the tldraw MCP server (MCP_ANALYTICS
dataset). Requires the MCP_ANALYTICS table in this datasource.
id: 7
links: []
title: MCP sessions started
vizConfig:
group: timeseries
kind: VizConfig
spec:
fieldConfig:
defaults:
color:
mode: palette-classic
custom:
axisBorderShow: false
axisCenteredZero: false
axisColorMode: text
axisLabel: ''
axisPlacement: auto
barAlignment: 0
barWidthFactor: 0.8
drawStyle: bars
fillOpacity: 80
gradientMode: none
hideFrom:
legend: false
tooltip: false
viz: false
insertNulls: false
lineInterpolation: linear
lineWidth: 0
pointSize: 5
scaleDistribution:
type: linear
showPoints: never
showValues: false
spanNulls: false
stacking:
group: A
mode: none
thresholdsStyle:
mode: 'off'
thresholds:
mode: absolute
steps:
- color: green
value: 0
overrides: []
options:
annotations:
clustering: -1
multiLane: false
legend:
calcs: []
displayMode: list
enableFacetedFilter: false
overflow: ellipsis
placement: bottom
showLegend: false
tooltip:
hideZeros: false
maxHeight: 600
mode: multi
sort: desc
version: 13.2.0-29854286369
panel-10:
kind: Panel
spec:
data:
kind: QueryGroup
spec:
queries:
- kind: PanelQuery
spec:
hidden: false
query:
datasource:
name: bdgu7tk03irr4e
group: vertamedia-clickhouse-datasource
kind: DataQuery
spec:
dateTimeType: DATETIME
editorMode: code
extrapolate: true
format: time_series
formatAs: time_series
intervalFactor: 1
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'
SECOND) as date, count() as sessions
FROM 'MCP_ANALYTICS'
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
AND blob1 = 'session_end'
GROUP BY date
ORDER BY date ASC"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'
SECOND) as date, count() as sessions
FROM 'MCP_ANALYTICS'
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
AND blob1 = 'session_end'
GROUP BY date
ORDER BY date ASC"
round: 0s
showFormattedSQL: false
showHelp: false
skip_comments: true
step: ''
version: v0
refId: A
queryOptions: {}
transformations: []
description: session_end events from the tldraw MCP server (MCP_ANALYTICS
dataset). Requires the MCP_ANALYTICS table in this datasource.
id: 10
links: []
title: MCP sessions ended
vizConfig:
group: timeseries
kind: VizConfig
spec:
fieldConfig:
defaults:
color:
mode: palette-classic
custom:
axisBorderShow: false
axisCenteredZero: false
axisColorMode: text
axisLabel: ''
axisPlacement: auto
barAlignment: 0
barWidthFactor: 0.8
drawStyle: bars
fillOpacity: 80
gradientMode: none
hideFrom:
legend: false
tooltip: false
viz: false
insertNulls: false
lineInterpolation: linear
lineWidth: 0
pointSize: 5
scaleDistribution:
type: linear
showPoints: never
showValues: false
spanNulls: false
stacking:
group: A
mode: none
thresholdsStyle:
mode: 'off'
thresholds:
mode: absolute
steps:
- color: green
value: 0
overrides: []
options:
annotations:
clustering: -1
multiLane: false
legend:
calcs: []
displayMode: list
enableFacetedFilter: false
overflow: ellipsis
placement: bottom
showLegend: false
tooltip:
hideZeros: false
maxHeight: 600
mode: multi
sort: desc
version: 13.2.0-29854286369
panel-8:
kind: Panel
spec:
data:
kind: QueryGroup
spec:
queries:
- kind: PanelQuery
spec:
hidden: false
query:
datasource:
name: bdgu7tk03irr4e
group: vertamedia-clickhouse-datasource
kind: DataQuery
spec:
dateTimeType: DATETIME
editorMode: code
extrapolate: true
format: time_series
formatAs: time_series
intervalFactor: 1
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'
SECOND) as date, blob2 as tool, count() as calls
FROM 'MCP_ANALYTICS'
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
AND blob1 = 'tool_called'
GROUP BY date, tool
ORDER BY date ASC"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'
SECOND) as date, blob2 as tool, count() as calls
FROM 'MCP_ANALYTICS'
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
AND blob1 = 'tool_called'
GROUP BY date, tool
ORDER BY date ASC"
round: 0s
showFormattedSQL: false
showHelp: false
skip_comments: true
step: ''
version: v0
refId: A
queryOptions: {}
transformations: []
description: tool_called events split by tool name (search, exec).
id: 8
links: []
title: MCP tool calls by tool
vizConfig:
group: timeseries
kind: VizConfig
spec:
fieldConfig:
defaults:
color:
mode: palette-classic
custom:
axisBorderShow: false
axisCenteredZero: false
axisColorMode: text
axisLabel: ''
axisPlacement: auto
barAlignment: 0
barWidthFactor: 0.8
drawStyle: bars
fillOpacity: 80
gradientMode: none
hideFrom:
legend: false
tooltip: false
viz: false
insertNulls: false
lineInterpolation: linear
lineWidth: 0
pointSize: 5
scaleDistribution:
type: linear
showPoints: never
showValues: false
spanNulls: false
stacking:
group: A
mode: normal
thresholdsStyle:
mode: 'off'
thresholds:
mode: absolute
steps:
- color: green
value: 0
overrides: []
options:
annotations:
clustering: -1
multiLane: false
legend:
calcs: []
displayMode: list
enableFacetedFilter: false
overflow: ellipsis
placement: bottom
showLegend: true
tooltip:
hideZeros: false
maxHeight: 600
mode: multi
sort: desc
version: 13.2.0-29854286369
panel-9:
kind: Panel
spec:
data:
kind: QueryGroup
spec:
queries:
- kind: PanelQuery
spec:
hidden: false
query:
datasource:
name: bdgu7tk03irr4e
group: vertamedia-clickhouse-datasource
kind: DataQuery
spec:
dateTimeType: DATETIME
editorMode: code
extrapolate: true
format: time_series
formatAs: time_series
intervalFactor: 1
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'
SECOND) as date, count() as reads
FROM 'MCP_ANALYTICS'
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
AND blob1 = 'resource_called'
GROUP BY date
ORDER BY date ASC"
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'
SECOND) as date, count() as reads
FROM 'MCP_ANALYTICS'
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
AND blob1 = 'resource_called'
GROUP BY date
ORDER BY date ASC"
round: 0s
showFormattedSQL: false
showHelp: false
skip_comments: true
step: ''
version: v0
refId: A
queryOptions: {}
transformations: []
description: resource_called events for the tldraw-canvas UI resource.
id: 9
links: []
title: MCP canvas resource reads
vizConfig:
group: timeseries
kind: VizConfig
spec:
fieldConfig:
defaults:
color:
mode: palette-classic
custom:
axisBorderShow: false
axisCenteredZero: false
axisColorMode: text
axisLabel: ''
axisPlacement: auto
barAlignment: 0
barWidthFactor: 0.8
drawStyle: line
fillOpacity: 25
gradientMode: opacity
hideFrom:
legend: false
tooltip: false
viz: false
insertNulls: false
lineInterpolation: linear
lineWidth: 1.5
pointSize: 5
scaleDistribution:
type: linear
showPoints: never
showValues: false
spanNulls: false
stacking:
group: A
mode: none
thresholdsStyle:
mode: 'off'
thresholds:
mode: absolute
steps:
- color: green
value: 0
overrides: []
options:
annotations:
clustering: -1
multiLane: false
legend:
calcs: []
displayMode: list
enableFacetedFilter: false
overflow: ellipsis
placement: bottom
showLegend: false
tooltip:
hideZeros: false
maxHeight: 600
mode: multi
sort: desc
version: 13.2.0-29854286369
layout:
kind: GridLayout
spec:
items:
- kind: GridLayoutItem
spec:
element:
kind: ElementReference
name: panel-6
height: 13
width: 12
x: 0
y: 0
- kind: GridLayoutItem
spec:
element:
kind: ElementReference
name: panel-1
height: 13
width: 12
x: 12
y: 0
- kind: GridLayoutItem
spec:
element:
kind: ElementReference
name: panel-4
height: 13
width: 12
x: 0
y: 13
- kind: GridLayoutItem
spec:
element:
kind: ElementReference
name: panel-2
height: 13
width: 12
x: 12
y: 13
- kind: GridLayoutItem
spec:
element:
kind: ElementReference
name: panel-7
height: 8
width: 6
x: 0
y: 26
- kind: GridLayoutItem
spec:
element:
kind: ElementReference
name: panel-10
height: 8
width: 6
x: 6
y: 26
- kind: GridLayoutItem
spec:
element:
kind: ElementReference
name: panel-8
height: 8
width: 6
x: 12
y: 26
- kind: GridLayoutItem
spec:
element:
kind: ElementReference
name: panel-9
height: 8
width: 6
x: 18
y: 26
links: []
liveNow: false
preload: false
tags:
- provisioned
- repo:tldraw/tldraw
timeSettings:
autoRefresh: ''
autoRefreshIntervals:
- 5s
- 10s
- 30s
- 1m
- 5m
- 15m
- 30m
- 1h
- 2h
- 1d
fiscalYearStartMonth: 0
from: now-6h
hideTimepicker: false
timezone: browser
to: now
title: MCP app sessions
variables: []

View File

@@ -0,0 +1,9 @@
apiVersion: folder.grafana.app/v1
kind: Folder
metadata:
annotations: {}
labels: {}
name: anthropic
namespace: stacks-890472
spec:
title: Anthropic

View File

@@ -0,0 +1,9 @@
apiVersion: folder.grafana.app/v1
kind: Folder
metadata:
annotations: {}
labels: {}
name: dotcom
namespace: stacks-890472
spec:
title: Dotcom

View File

@@ -0,0 +1,9 @@
apiVersion: folder.grafana.app/v1
kind: Folder
metadata:
annotations: {}
labels: {}
name: f6hsw6
namespace: stacks-890472
spec:
title: Zero

View File

@@ -0,0 +1,22 @@
{{ define "tldraw.discord.short" -}}
{{ if eq .Labels.alertname "DatasourceNoData" }}No metrics: {{ .Labels.rulename }}{{ else if .Annotations.summary }}{{ .Annotations.summary }}{{ else }}{{ .Labels.alertname }}{{ end }}
{{- end }}
{{ define "tldraw.discord.title" -}}
{{ if eq .Status "firing" }}🔴{{ else }}✅{{ end }} {{ if gt (len .Alerts) 1 }}[{{ len .Alerts }} alerts] {{ end }}{{ template "tldraw.discord.short" (index .Alerts 0) }}
{{- end }}
{{ define "tldraw.discord.alert" -}}
{{ if eq .Labels.alertname "DatasourceNoData" -}}
⚠️ **No metrics** for **{{ .Labels.rulename }}** (usually the metrics source is down - for Zero rules: Fly.io metrics - not the alert condition itself)
{{- else -}}
{{ with .Labels.severity }}**{{ . | toUpper }}** · {{ end }}{{ template "tldraw.discord.short" . }}
{{- end }} · [View]({{ .GeneratorURL }}){{ with .DashboardURL }} · [Dashboard]({{ . }}){{ end }}
{{- end }}
{{ define "tldraw.discord.text" -}}
{{ range .Alerts.Firing }}{{ template "tldraw.discord.alert" . }}
{{ end -}}
{{ range .Alerts.Resolved }}✅ Resolved: {{ template "tldraw.discord.alert" . }}
{{ end -}}
{{- end }}

View File

@@ -0,0 +1,26 @@
{{ define "tldraw.email.short" -}}
{{ if eq .Labels.alertname "DatasourceNoData" }}No metrics: {{ .Labels.rulename }}{{ else if .Annotations.summary }}{{ .Annotations.summary }}{{ else }}{{ .Labels.alertname }}{{ end }}
{{- end }}
{{ define "tldraw.email.subject" -}}
{{ if eq .Status "firing" }}🔴{{ else }}✅{{ end }} {{ if gt (len .Alerts) 1 }}[{{ len .Alerts }} alerts] {{ end }}{{ template "tldraw.email.short" (index .Alerts 0) }}
{{- end }}
{{ define "tldraw.email.alert" -}}
{{ if eq .Labels.alertname "DatasourceNoData" -}}
No metrics for {{ .Labels.rulename }} - the datasource returned no series. Usually the metrics source is down (for Zero rules: Fly.io metrics), not the alert condition itself.
{{ else -}}
{{ with .Labels.severity }}{{ . | toUpper }} · {{ end }}{{ template "tldraw.email.short" . }}
{{ end -}}
View: {{ .GeneratorURL }}
Silence: {{ .SilenceURL }}
{{- end }}
{{ define "tldraw.email.message" -}}
{{ range .Alerts.Firing }}FIRING · {{ template "tldraw.email.alert" . }}
{{ end -}}
{{ range .Alerts.Resolved }}RESOLVED · {{ template "tldraw.email.alert" . }}
{{ end -}}
{{- end }}

2
internal/health-worker/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
build
.wrangler

View File

@@ -0,0 +1,27 @@
# Health Worker
Accepts webhooks from [Updown](https://updown.io/), sends them to our Discord.
## 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).

View File

@@ -0,0 +1,23 @@
{
"name": "health-worker",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev",
"check-bundle-size": "yarn run -T tsx ../scripts/check-worker-bundle.ts --entry src/index.ts --size-limit-bytes 40000",
"lint": "yarn run -T tsx ../scripts/lint.ts"
},
"dependencies": {
"@tldraw/utils": "workspace:*"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260302.0",
"@types/node": "^22.15.31",
"discord-api-types": "^0.37.118",
"esbuild": "^0.28.0",
"typescript": "^7.0.2",
"wrangler": "^4.75.0"
}
}

View File

@@ -0,0 +1,96 @@
import { exhaustiveSwitchError } from '@tldraw/utils'
import { type APIEmbed } from 'discord-api-types/v10'
import { Event as UpdownEvent } from './updown_types'
// discord wants decimal colours
const GREEN = 4243543
const RED = 14692657
const ORANGE = 16213767
// docs: https://birdie0.github.io/discord-webhooks-guide/index.html
export interface DiscordPayload {
username: string
content: string
embeds: APIEmbed[]
}
function formatUpdownEvent(event: UpdownEvent): {
colour: number
title: string
description: string
} | null {
switch (event.event) {
case 'check.down':
return {
colour: RED,
title: `Check DOWN at <${event.check.url}>`,
description: `<${event.check.url}> is down: "${event.downtime.error}"\n\nNext check in ${event.check.period} seconds`,
}
case 'check.still_down':
return null
case 'check.up': {
return {
colour: GREEN,
title: `Check UP at <${event.check.url}>`,
description: `<${event.check.url}> is up\n\nIt was down for ${event.downtime.duration} seconds`,
}
}
case 'check.ssl_invalid': {
return {
colour: RED,
title: `SSL INVALID at <${event.check.url}>`,
description: `SSL certificate at <${event.check.url}> is invalid: "${event.ssl.error}"`,
}
}
case 'check.ssl_valid': {
return {
colour: GREEN,
title: `SSL VALID at <${event.check.url}>`,
description: `SSL certificate at <${event.check.url}> is now valid`,
}
}
case 'check.ssl_expiration': {
return {
colour: ORANGE,
title: `SSL EXPIRATION at <${event.check.url}>`,
description: `SSL certificate at <${event.check.url}> will expire in ${event.ssl.days_before_expiration} days`,
}
}
case 'check.ssl_renewed': {
return {
colour: GREEN,
title: `SSL RENEWED at <${event.check.url}>`,
description: `SSL certificate at <${event.check.url}> was renewed`,
}
}
case 'check.performance_drop':
return {
colour: ORANGE,
title: `PERFORMANCE DROP at <${event.check.url}>`,
description: `Performance drop at <${event.check.url}>, apdex dropped ${event.apdex_dropped}`,
}
default:
exhaustiveSwitchError(event, 'event')
}
}
export function updownToDiscord(event: UpdownEvent): DiscordPayload | null {
const formatted = formatUpdownEvent(event)
if (!formatted) return null
const { colour, title, description } = formatted
return {
username: 'Health Worker',
content: `Updown: ${title}`,
embeds: [
{
color: colour,
description: description,
timestamp: event.time,
},
],
}
}

View File

@@ -0,0 +1,69 @@
import { DiscordPayload, updownToDiscord } from './discord'
import { Event as UpdownEvent } from './updown_types'
interface Env {
DISCORD_HEALTH_WEBHOOK_URL: string | undefined
// it needs to be passed in because it's effectively a secret, unless we want everyone to be able
// to stress us out with spurious discord alerts
HEALTH_WORKER_UPDOWN_WEBHOOK_PATH: string | undefined
}
async function sendDiscordWebhook(url: string, discord: DiscordPayload): Promise<Response> {
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(discord),
})
}
async function handleUpdown(request: Request, discordUrl: string): Promise<Response> {
const updownEvents = (await request.json()) as Array<UpdownEvent>
let status = 200
for (const e of updownEvents) {
const discordPayload = updownToDiscord(e)
if (!discordPayload) {
continue
}
const discordResult = await sendDiscordWebhook(discordUrl, discordPayload)
if (!discordResult.ok) {
console.error(`Discord error ${discordResult.status}: ${discordResult.statusText}`)
status = discordResult.status
break
}
}
return new Response(null, { status })
}
const handler: ExportedHandler<Env> = {
async fetch(request: Request, env: Env): Promise<Response> {
const discordUrl = env.DISCORD_HEALTH_WEBHOOK_URL
if (!discordUrl) {
console.error('missing DISCORD_HEALTH_WEBHOOK_URL')
return new Response('Internal error', { status: 500 })
}
const updownWebhookPath = env.HEALTH_WORKER_UPDOWN_WEBHOOK_PATH
if (!updownWebhookPath) {
console.error('missing HEALTH_WORKER_UPDOWN_WEBHOOK_PATH')
return new Response('Internal error', { status: 500 })
}
const url = new URL(request.url)
// timing safety COULD be an issue, but it seems that in practice it isn't:
// https://github.com/scriptin/node-timing-attack
// my own testing confirms those observations
if (url.pathname === updownWebhookPath) {
return handleUpdown(request, discordUrl)
}
return new Response('Not Found', { status: 404 })
},
}
export default handler

View File

@@ -0,0 +1,152 @@
// docs: https://updown.io/api#webhooks
interface BaseCheck {
token: string
url: string
alias: null
last_status: number
uptime: number
period: number
apdex_t: number
string_match: string
enabled: boolean
published: boolean
disabled_locations: any[]
recipients: any[]
last_check_at: string
next_check_at: string
created_at: null
mute_until: null | string
favicon_url: string
custom_headers: CustomHeaders
http_verb: string
http_body: string
}
interface FailingCheck extends BaseCheck {
down: true
down_since: string
up_since: null
error: string
}
interface SucceedingCheck extends BaseCheck {
down: true
down_since: null
up_since: string
error: null
}
interface BaseDowntime {
id: string
error: string
started_at: string
partial: unknown
}
interface OngoingDowntime extends BaseDowntime {
ended_at: null
duration: null
}
interface FinishedDowntime extends BaseDowntime {
ended_at: string
// seconds
duration: number
}
type CustomHeaders = Record<string, string>
interface SslCert {
subject: string
issuer: string
from: string
to: string
algorithm: string
}
interface EventDown {
event: 'check.down'
time: string
description: string
check: FailingCheck
downtime: OngoingDowntime
}
interface EventStillDown {
event: 'check.still_down'
time: string
description: string
check: FailingCheck
downtime: OngoingDowntime
}
interface EventUp {
event: 'check.up'
time: string
description: string
check: SucceedingCheck
downtime: FinishedDowntime
}
interface EventSslInvalid {
event: 'check.ssl_invalid'
time: string
description: string
check: SucceedingCheck | FailingCheck
ssl: {
cert: SslCert
error: string
}
}
interface EventSslValid {
event: 'check.ssl_valid'
time: string
description: string
check: SucceedingCheck | FailingCheck
ssl: {
cert: SslCert
}
}
interface EventSslExpiration {
event: 'check.ssl_expiration'
time: string
description: string
check: SucceedingCheck | FailingCheck
ssl: {
cert: SslCert
days_before_expiration: number
}
}
interface EventSslRenewed {
event: 'check.ssl_renewed'
time: string
description: string
check: SucceedingCheck | FailingCheck
ssl: {
new_cert: SslCert
old_cert: SslCert
}
}
interface EventPerformanceDrop {
event: 'check.performance_drop'
time: string
description: string
check: SucceedingCheck | FailingCheck
apdex_dropped: string
last_metrics: Record<string, { apdex: number }>
}
export type Event =
| EventDown
| EventStillDown
| EventUp
| EventSslInvalid
| EventSslValid
| EventSslExpiration
| EventSslRenewed
| EventPerformanceDrop

View File

@@ -0,0 +1,11 @@
{
"extends": "../config/tsconfig.base.json",
"include": ["src"],
"exclude": ["node_modules", "dist", ".tsbuild*"],
"compilerOptions": {
"noEmit": true,
"emitDeclarationOnly": false,
"types": ["@cloudflare/workers-types", "@types/node"]
},
"references": [{ "path": "../../packages/utils" }]
}

View File

@@ -0,0 +1,17 @@
main = "src/index.ts"
compatibility_date = "2023-12-18"
#################### Environment names ####################
# dev should never actually get deployed anywhere
[env.dev]
name = "health-worker-dev"
# we don't have a hard-coded name for preview. we instead have to generate it at build time and append it to this file.
# staging is the same as a preview on main:
[env.staging]
name = "health-worker-staging"
# production gets the proper name
[env.production]
name = "health-worker-production"

102
internal/scripts/api-check.ts Executable file
View File

@@ -0,0 +1,102 @@
import { readFileSync, writeFileSync } from 'fs'
import { join, resolve } from 'path'
import { exec } from './lib/exec'
import { readFileIfExists } from './lib/file'
import { nicelog } from './lib/nicelog'
import { getAllWorkspacePackages } from './lib/workspace'
const packagesOurTypesCanDependOn = [
'@types/lodash.throttle',
'@types/lodash.uniq',
'@types/lodash.isequal',
'@types/lodash.isequalwith',
'@types/react',
'@types/react-dom',
'eventemitter3',
'nanoevents',
'react',
'react-dom',
]
// These packages use subpath exports which require moduleResolution: "bundler".
// We pin to the workspace's resolved versions to avoid installing broken releases.
const pinnedPackages = [
'@tiptap/core',
'@tiptap/extension-list',
'@tiptap/extension-mention',
'@tiptap/pm',
'@tiptap/react',
'@tiptap/starter-kit',
'@tiptap/suggestion',
]
function getPinnedVersions(): string[] {
return pinnedPackages.map((pkg) => {
const pkgJsonPath = resolve(`./node_modules/${pkg}/package.json`)
const { version } = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
return `${pkg}@${version}`
})
}
function getTempDeclarationFilename(packageName: string): string {
// Keep filenames unique across similarly named packages like `tldraw` and `@tldraw/tldraw`.
return (
packageName
.replace(/^@/, '')
.replace(/\//g, '__')
.replace(/[^a-zA-Z0-9_.-]/g, '_') + '.d.ts'
)
}
main()
async function main() {
const tsconfig: any = {
compilerOptions: {
lib: ['esnext', 'dom'],
strict: true,
module: 'esnext',
moduleResolution: 'bundler',
rootDir: '.',
paths: {},
esModuleInterop: true,
},
files: [],
}
const tempDir = (await exec('mktemp', ['-d'])).trim()
nicelog(`Working in ${tempDir}`)
const packages = (await getAllWorkspacePackages())
.filter(({ packageJson }) => !packageJson.private)
.sort((a, b) => a.name.localeCompare(b.name))
nicelog(
'Checking packages:',
packages.map(({ packageJson }) => packageJson.name)
)
for (const { name, relativePath } of packages) {
const declarationFilename = getTempDeclarationFilename(name)
const dtsFile = await readFileIfExists(join(relativePath, 'api', 'public.d.ts'))
if (!dtsFile) {
nicelog(`No public.d.ts for ${name}, skipping`)
continue
}
writeFileSync(join(tempDir, declarationFilename), dtsFile, 'utf8')
tsconfig.compilerOptions.paths[name] = [`./${declarationFilename}`]
tsconfig.files.push(`./${declarationFilename}`)
}
nicelog('Checking with tsconfig:', tsconfig)
writeFileSync(`${tempDir}/tsconfig.json`, JSON.stringify(tsconfig, null, '\t'), 'utf8')
writeFileSync(`${tempDir}/package.json`, JSON.stringify({ dependencies: {} }, null, '\t'), 'utf8')
await exec('npm', ['install', ...packagesOurTypesCanDependOn, ...getPinnedVersions()], {
pwd: tempDir,
})
await exec(resolve('./node_modules/.bin/tsc'), [], { pwd: tempDir })
await exec('rm', ['-rf', tempDir])
}

View File

@@ -0,0 +1,292 @@
/**
* Benchmark script for @tldraw/validate DictValidator
*
* Usage:
* yarn tsx internal/scripts/benchmark-dict-validator.ts
*
* This script measures the performance of the DictValidator in @tldraw/validate.
* It tests both fresh validation and validateUsingKnownGoodVersion scenarios.
*/
import * as T from '../../packages/validate/src/lib/validation'
const ITERATIONS_PER_RUN = 50_000
const TOTAL_RUNS = 200
interface BenchmarkResult {
first100Avg: number
last100Avg: number
opsPerSecFirst: number
opsPerSecLast: number
}
function runBenchmark(name: string, fn: () => void): BenchmarkResult {
const times: number[] = []
for (let run = 0; run < TOTAL_RUNS; run++) {
const start = performance.now()
for (let i = 0; i < ITERATIONS_PER_RUN; i++) {
fn()
}
times.push(performance.now() - start)
}
const first100 = times.slice(0, 100)
const last100 = times.slice(100)
const first100Avg = first100.reduce((a, b) => a + b, 0) / first100.length
const last100Avg = last100.reduce((a, b) => a + b, 0) / last100.length
return {
first100Avg,
last100Avg,
opsPerSecFirst: (ITERATIONS_PER_RUN / first100Avg) * 1000,
opsPerSecLast: (ITERATIONS_PER_RUN / last100Avg) * 1000,
}
}
function formatOps(ops: number): string {
if (ops >= 1_000_000) {
return `${(ops / 1_000_000).toFixed(2)}M`
}
if (ops >= 1_000) {
return `${(ops / 1_000).toFixed(2)}K`
}
return ops.toFixed(0)
}
function printResult(name: string, result: BenchmarkResult): void {
const warmupChange = ((result.first100Avg / result.last100Avg - 1) * 100).toFixed(1)
const warmupDir = Number(warmupChange) > 0 ? 'slower' : 'faster'
console.log(`--- ${name} ---`)
console.log(
` First 100 runs: ${result.first100Avg.toFixed(2)}ms (${formatOps(result.opsPerSecFirst)} ops/sec)`
)
console.log(
` Last 100 runs: ${result.last100Avg.toFixed(2)}ms (${formatOps(result.opsPerSecLast)} ops/sec)`
)
console.log(` JIT stability: First 100 ${Math.abs(Number(warmupChange))}% ${warmupDir}`)
console.log('')
}
// Test data for dict validators
// Small dict (3 keys) - common case for simple configs
const smallDict: Record<string, number> = {
width: 100,
height: 200,
depth: 50,
}
// Medium dict (10 keys) - typical shape props
const mediumDict: Record<string, number> = {}
for (let i = 0; i < 10; i++) {
mediumDict[`key${i}`] = i * 10
}
// Large dict (50 keys) - stress test
const largeDict: Record<string, number> = {}
for (let i = 0; i < 50; i++) {
largeDict[`key${i}`] = i * 10
}
// Line shape points scenario - realistic use case
interface LinePoint {
id: string
index: string
x: number
y: number
}
const linePoints: Record<string, LinePoint> = {}
for (let i = 0; i < 5; i++) {
const id = `point${i}`
linePoints[id] = { id, index: `a${i}`, x: i * 10, y: i * 20 }
}
// Validators
const stringToNumberDict = T.dict(T.string, T.number)
const linePointValidator = T.object({
id: T.string,
index: T.string,
x: T.number,
y: T.number,
})
const linePointsDict = T.dict(T.string, linePointValidator)
console.log('='.repeat(70))
console.log('DICT VALIDATOR BENCHMARK')
console.log('='.repeat(70))
console.log(`Iterations per run: ${ITERATIONS_PER_RUN.toLocaleString()}`)
console.log(`Total runs: ${TOTAL_RUNS}`)
console.log(`Comparing first 100 runs (warmup) vs last 100 runs (stable)`)
console.log('')
// --- Fresh validation benchmarks ---
console.log('='.repeat(70))
console.log('FRESH VALIDATION (validate)')
console.log('='.repeat(70))
console.log('')
// Small dict fresh validation
{
const testDicts = [smallDict, { ...smallDict, extra: 999 }, { a: 1, b: 2, c: 3 }]
let idx = 0
printResult(
'Small dict (3 keys)',
runBenchmark('SmallDict', () => {
stringToNumberDict.validate(testDicts[idx++ % testDicts.length])
})
)
}
// Medium dict fresh validation
{
const testDicts = [mediumDict, { ...mediumDict }]
let idx = 0
printResult(
'Medium dict (10 keys)',
runBenchmark('MediumDict', () => {
stringToNumberDict.validate(testDicts[idx++ % testDicts.length])
})
)
}
// Large dict fresh validation
{
const testDicts = [largeDict, { ...largeDict }]
let idx = 0
printResult(
'Large dict (50 keys)',
runBenchmark('LargeDict', () => {
stringToNumberDict.validate(testDicts[idx++ % testDicts.length])
})
)
}
// Line points dict (realistic scenario)
{
const testDicts = [linePoints, { ...linePoints }]
let idx = 0
printResult(
'Line points dict (5 nested objects)',
runBenchmark('LinePoints', () => {
linePointsDict.validate(testDicts[idx++ % testDicts.length])
})
)
}
// --- ValidateUsingKnownGoodVersion benchmarks ---
console.log('='.repeat(70))
console.log('INCREMENTAL VALIDATION (validateUsingKnownGoodVersion)')
console.log('='.repeat(70))
console.log('')
// Scenario 1: No changes (should be fast - identity check)
{
const knownGood = stringToNumberDict.validate(smallDict)
printResult(
'Small dict - no changes (same reference)',
runBenchmark('SmallDictNoChange', () => {
stringToNumberDict.validateUsingKnownGoodVersion(knownGood, knownGood)
})
)
}
// Scenario 2: Same values, different reference
{
const knownGood = stringToNumberDict.validate(smallDict)
const newValue = { ...smallDict } // Same values, new object
printResult(
'Small dict - same values, different reference',
runBenchmark('SmallDictSameValues', () => {
stringToNumberDict.validateUsingKnownGoodVersion(knownGood, newValue)
})
)
}
// Scenario 3: One value changed
{
const knownGood = stringToNumberDict.validate(smallDict)
const changed = { ...smallDict, width: 150 }
printResult(
'Small dict - one value changed',
runBenchmark('SmallDictOneChange', () => {
stringToNumberDict.validateUsingKnownGoodVersion(knownGood, changed)
})
)
}
// Scenario 4: Key added
{
const knownGood = stringToNumberDict.validate(smallDict)
const added = { ...smallDict, newKey: 999 }
printResult(
'Small dict - key added',
runBenchmark('SmallDictKeyAdded', () => {
stringToNumberDict.validateUsingKnownGoodVersion(knownGood, added)
})
)
}
// Scenario 5: Key removed
{
const { width: _, ...rest } = smallDict
const knownGood = stringToNumberDict.validate(smallDict)
printResult(
'Small dict - key removed',
runBenchmark('SmallDictKeyRemoved', () => {
stringToNumberDict.validateUsingKnownGoodVersion(knownGood, rest)
})
)
}
// Medium dict scenarios
{
const knownGood = stringToNumberDict.validate(mediumDict)
const newValue = { ...mediumDict }
printResult(
'Medium dict - same values, different reference',
runBenchmark('MediumDictSameValues', () => {
stringToNumberDict.validateUsingKnownGoodVersion(knownGood, newValue)
})
)
}
{
const knownGood = stringToNumberDict.validate(mediumDict)
const changed = { ...mediumDict, key5: 999 }
printResult(
'Medium dict - one value changed',
runBenchmark('MediumDictOneChange', () => {
stringToNumberDict.validateUsingKnownGoodVersion(knownGood, changed)
})
)
}
// Line points incremental
{
const knownGood = linePointsDict.validate(linePoints)
const newValue = { ...linePoints }
printResult(
'Line points - same values, different reference',
runBenchmark('LinePointsSameValues', () => {
linePointsDict.validateUsingKnownGoodVersion(knownGood, newValue)
})
)
}
{
const knownGood = linePointsDict.validate(linePoints)
const changed = {
...linePoints,
point2: { ...linePoints.point2, x: 999 },
}
printResult(
'Line points - one point changed',
runBenchmark('LinePointsOneChange', () => {
linePointsDict.validateUsingKnownGoodVersion(knownGood, changed)
})
)
}
console.log('='.repeat(70))
console.log('')

View File

@@ -0,0 +1,379 @@
/**
* Benchmark script for @tldraw/validate number validators
*
* Usage:
* yarn tsx internal/scripts/benchmark-validators.ts
*
* This script measures the performance of the numeric validators in @tldraw/validate.
* It runs multiple iterations to get stable measurements after JIT warmup.
*/
import * as T from '../../packages/validate/src/lib/validation'
const ITERATIONS_PER_RUN = 100_000
const TOTAL_RUNS = 200
interface BenchmarkResult {
first100Avg: number
last100Avg: number
opsPerSecFirst: number
opsPerSecLast: number
}
function runBenchmark(name: string, fn: () => void): BenchmarkResult {
const times: number[] = []
for (let run = 0; run < TOTAL_RUNS; run++) {
const start = performance.now()
for (let i = 0; i < ITERATIONS_PER_RUN; i++) {
fn()
}
times.push(performance.now() - start)
}
const first100 = times.slice(0, 100)
const last100 = times.slice(100)
const first100Avg = first100.reduce((a, b) => a + b, 0) / first100.length
const last100Avg = last100.reduce((a, b) => a + b, 0) / last100.length
return {
first100Avg,
last100Avg,
opsPerSecFirst: (ITERATIONS_PER_RUN / first100Avg) * 1000,
opsPerSecLast: (ITERATIONS_PER_RUN / last100Avg) * 1000,
}
}
function formatOps(ops: number): string {
if (ops >= 1_000_000) {
return `${(ops / 1_000_000).toFixed(2)}M`
}
if (ops >= 1_000) {
return `${(ops / 1_000).toFixed(2)}K`
}
return ops.toFixed(0)
}
function printResult(name: string, result: BenchmarkResult): void {
const warmupChange = ((result.first100Avg / result.last100Avg - 1) * 100).toFixed(1)
const warmupDir = Number(warmupChange) > 0 ? 'slower' : 'faster'
console.log(`--- ${name} ---`)
console.log(
` First 100 runs: ${result.first100Avg.toFixed(2)}ms (${formatOps(result.opsPerSecFirst)} ops/sec)`
)
console.log(
` Last 100 runs: ${result.last100Avg.toFixed(2)}ms (${formatOps(result.opsPerSecLast)} ops/sec)`
)
console.log(` JIT stability: First 100 ${Math.abs(Number(warmupChange))}% ${warmupDir}`)
console.log('')
}
// Test values
const testIntegers = [0, 1, 42, -100, 1000000]
const testFloats = [0.5, 3.14, -2.5, 100.001, 0.000001]
const testPositive = [0, 1, 42, 0.5, 1000000]
const testNonZero = [1, 42, 0.5, 100.001, 1000000]
const testUnitInterval = [0, 0.25, 0.5, 0.75, 1]
const testNonZeroFinite = [1, -1, 0.5, -0.5, 100.001]
console.log('='.repeat(70))
console.log('VALIDATOR BENCHMARK')
console.log('='.repeat(70))
console.log(`Iterations per run: ${ITERATIONS_PER_RUN.toLocaleString()}`)
console.log(`Total runs: ${TOTAL_RUNS}`)
console.log(`Comparing first 100 runs (warmup) vs last 100 runs (stable)`)
console.log('')
// T.number
{
const vals = [...testIntegers, ...testFloats]
let idx = 0
printResult(
'T.number',
runBenchmark('T.number', () => {
T.number.validate(vals[idx++ % vals.length])
})
)
}
// T.positiveNumber
{
let idx = 0
printResult(
'T.positiveNumber',
runBenchmark('T.positiveNumber', () => {
T.positiveNumber.validate(testPositive[idx++ % testPositive.length])
})
)
}
// T.nonZeroNumber
{
let idx = 0
printResult(
'T.nonZeroNumber',
runBenchmark('T.nonZeroNumber', () => {
T.nonZeroNumber.validate(testNonZero[idx++ % testNonZero.length])
})
)
}
// T.integer
{
let idx = 0
printResult(
'T.integer',
runBenchmark('T.integer', () => {
T.integer.validate(testIntegers[idx++ % testIntegers.length])
})
)
}
// T.positiveInteger
{
let idx = 0
printResult(
'T.positiveInteger',
runBenchmark('T.positiveInteger', () => {
T.positiveInteger.validate(testPositive.filter(Number.isInteger)[idx++ % 3])
})
)
}
// T.nonZeroFiniteNumber
{
let idx = 0
printResult(
'T.nonZeroFiniteNumber',
runBenchmark('T.nonZeroFiniteNumber', () => {
T.nonZeroFiniteNumber.validate(testNonZeroFinite[idx++ % testNonZeroFinite.length])
})
)
}
// T.unitInterval
{
let idx = 0
printResult(
'T.unitInterval',
runBenchmark('T.unitInterval', () => {
T.unitInterval.validate(testUnitInterval[idx++ % testUnitInterval.length])
})
)
}
console.log('='.repeat(70))
console.log('')
// Union validator benchmarks
console.log('='.repeat(70))
console.log('UNION VALIDATOR')
console.log('='.repeat(70))
console.log('')
const circleSchema = T.object({
type: T.literal('circle'),
radius: T.number,
})
const squareSchema = T.object({
type: T.literal('square'),
size: T.number,
})
const triangleSchema = T.object({
type: T.literal('triangle'),
base: T.number,
height: T.number,
})
const shapeUnion = T.union('type', {
circle: circleSchema,
square: squareSchema,
triangle: triangleSchema,
})
const testShapes = [
{ type: 'circle', radius: 10 },
{ type: 'square', size: 20 },
{ type: 'triangle', base: 15, height: 25 },
{ type: 'circle', radius: 5 },
{ type: 'square', size: 30 },
]
// Fresh validation
{
let idx = 0
printResult(
'Union fresh validation',
runBenchmark('UnionFresh', () => {
shapeUnion.validate(testShapes[idx++ % testShapes.length])
})
)
}
// validateUsingKnownGoodVersion - same reference (tests Object.is fast path)
{
const knownGood = shapeUnion.validate({ type: 'circle', radius: 10 })
printResult(
'Union same reference (Object.is fast path)',
runBenchmark('UnionSameRef', () => {
shapeUnion.validateUsingKnownGoodVersion(knownGood, knownGood)
})
)
}
// validateUsingKnownGoodVersion - same type, value changed
{
const knownGood = shapeUnion.validate({ type: 'circle', radius: 10 })
const changed = { type: 'circle', radius: 20 }
printResult(
'Union same type, value changed',
runBenchmark('UnionSameType', () => {
shapeUnion.validateUsingKnownGoodVersion(knownGood, changed)
})
)
}
// validateUsingKnownGoodVersion - different type
{
const knownGood = shapeUnion.validate({ type: 'circle', radius: 10 })
const different = { type: 'square', size: 20 }
printResult(
'Union different type',
runBenchmark('UnionDiffType', () => {
shapeUnion.validateUsingKnownGoodVersion(knownGood, different)
})
)
}
// Number union (tests optimized NaN check)
const numberUnionSchema = T.numberUnion('version', {
1: T.object({ version: T.literal(1), data: T.string }),
2: T.object({ version: T.literal(2), data: T.string, extra: T.number }),
})
const testNumberUnions = [
{ version: 1, data: 'hello' },
{ version: 2, data: 'world', extra: 42 },
{ version: 1, data: 'foo' },
{ version: 2, data: 'bar', extra: 100 },
]
{
let idx = 0
printResult(
'NumberUnion fresh validation',
runBenchmark('NumberUnionFresh', () => {
numberUnionSchema.validate(testNumberUnions[idx++ % testNumberUnions.length])
})
)
}
console.log('='.repeat(70))
console.log('')
// Realistic scenario: Point validation
console.log('='.repeat(70))
console.log('REALISTIC SCENARIO: Point validation')
console.log('='.repeat(70))
console.log('')
const testPoints: unknown[] = []
for (let i = 0; i < 10000; i++) {
testPoints.push({ x: Math.random() * 1000, y: Math.random() * 1000 })
}
const pointValidator = T.object({
x: T.number,
y: T.number,
})
{
let idx = 0
printResult(
'Point {x, y}',
runBenchmark('Point', () => {
pointValidator.validate(testPoints[idx++ % testPoints.length])
})
)
}
// Draw shape props scenario
const testDrawProps: unknown[] = []
for (let i = 0; i < 1000; i++) {
testDrawProps.push({
scaleX: 1 + Math.random() * 0.5,
scaleY: 1 + Math.random() * 0.5,
opacity: Math.random(),
})
}
const drawPropsValidator = T.object({
scaleX: T.nonZeroFiniteNumber,
scaleY: T.nonZeroFiniteNumber,
opacity: T.unitInterval,
})
{
let idx = 0
printResult(
'DrawProps {scaleX, scaleY, opacity}',
runBenchmark('DrawProps', () => {
drawPropsValidator.validate(testDrawProps[idx++ % testDrawProps.length])
})
)
}
console.log('='.repeat(70))
console.log('')
// Factory method caching benchmarks
console.log('='.repeat(70))
console.log('FACTORY METHOD CACHING')
console.log('='.repeat(70))
console.log('')
// Test repeated .nullable() calls on same validator
{
printResult(
'T.string.nullable() repeated calls',
runBenchmark('NullableRepeated', () => {
T.string.nullable()
})
)
}
// Test repeated .optional() calls on same validator
{
printResult(
'T.string.optional() repeated calls',
runBenchmark('OptionalRepeated', () => {
T.string.optional()
})
)
}
// Test chained .nullable().optional() calls
{
printResult(
'T.number.nullable().optional() chain',
runBenchmark('NullableOptionalChain', () => {
T.number.nullable().optional()
})
)
}
// Realistic: validate with nullable field
{
const nullableStringValidator = T.string.nullable()
const testValues = ['hello', null, 'world', null, 'test']
let idx = 0
printResult(
'Validate with cached nullable',
runBenchmark('ValidateCachedNullable', () => {
nullableStringValidator.validate(testValues[idx++ % testValues.length])
})
)
}
console.log('='.repeat(70))

View File

@@ -0,0 +1,62 @@
import path from 'path'
import { glob } from 'glob'
import isCI from 'is-ci'
import { rimraf } from 'rimraf'
import { exec } from './lib/exec'
import { sortUnions } from './lib/sort-unions'
async function buildApi(sourcePackageDir: string) {
// this depends on `build-types` being run first, but we'll rely on turbo to
// make that happen.
const relativeSourcePackageDir = path.relative(path.resolve(__dirname, '../..'), sourcePackageDir)
// we need to copy the .tsbuild dir to another location so we can edit it without busting the cache
await rimraf(path.join(sourcePackageDir, '.tsbuild-api'))
await exec('cp', ['-R', './.tsbuild', './.tsbuild-api'], { pwd: sourcePackageDir })
sortUnions(path.join(sourcePackageDir, '.tsbuild-api'))
// clear api-extractor build files
rimraf.sync(glob.sync(path.join(sourcePackageDir, 'api')))
// extract public api
try {
await exec('yarn', ['run', '-T', 'api-extractor', 'run', isCI ? null : '--local'], {
pwd: sourcePackageDir,
processStderrLine: (line) => {
process.stderr.write(`${line}\n`)
if (process.env.PRINT_GITHUB_ANNOTATIONS) {
const errorMatch = line.match(
// eslint-disable-next-line no-control-regex
/^(?:\u001b\[33m)?(Warning|Error): (.+?):(\d+):(\d+) - \((.*)\) (.*)(?:\u001b\[39m)?$/
)
if (errorMatch) {
const [, errorType, file, line, column, messageTitle, message] = errorMatch
process.stdout.write(
`::${errorType.toLowerCase()} file=${relativeSourcePackageDir}/${file},line=${line},col=${column},title=${messageTitle}::${message}}\n`
)
}
}
},
})
} catch (e) {
// get git diff
await exec('git', [
'diff',
'--no-color',
'--no-ext-diff',
'--no-index',
'api-report.api.md',
'api/temp/api-report.api.md',
])
// @ts-expect-error
console.error(e.message)
process.exit(1)
}
}
buildApi(process.cwd())

View File

@@ -0,0 +1,137 @@
import { copyFileSync, existsSync } from 'fs'
import path from 'path'
import { pathToFileURL } from 'url'
import { build } from 'esbuild'
import { glob } from 'glob'
import kleur from 'kleur'
import rimraf from 'rimraf'
import { addJsExtensions } from './lib/add-extensions'
import { readJsonIfExists } from './lib/file'
interface LibraryInfo {
name: string
version: string
moduleSystem: 'esm' | 'cjs'
}
/** Prepares the package for publishing. the tarball in case it will be written to disk. */
async function buildPackage({ sourcePackageDir }: { sourcePackageDir: string }) {
// this depends on `build-types` being run first, but we'll rely on turbo to
// make that happen.
if (!existsSync(path.join(sourcePackageDir, 'src/index.ts'))) {
throw new Error(`No src/index.ts file found in '${sourcePackageDir}'!`)
}
rimraf.sync(path.join(sourcePackageDir, 'dist'))
// then copy over the source .ts files
const sourceFiles = glob
.sync(path.join(sourcePackageDir, 'src/**/*.ts?(x)'))
// ignore test files
.filter(
(file) =>
!(file.includes('/__tests__/') || file.includes('.test.') || file.includes('/test/'))
)
const packageJson = await readJsonIfExists(path.join(sourcePackageDir, 'package.json'))
if (!packageJson) {
throw new Error('No package.json found in source package dir')
}
const packageName = packageJson.name
if (typeof packageName !== 'string') throw new Error('package.json name is not a string')
const packageVersion = packageJson.version
if (typeof packageVersion !== 'string') throw new Error('package.json version is not a string')
// build CommonJS files to /dist-cjs
await buildLibrary({
sourceFiles,
sourcePackageDir,
info: {
name: packageName,
version: packageVersion,
moduleSystem: 'cjs',
},
})
// build ES Module files to /dist-esm
await buildLibrary({
sourceFiles,
sourcePackageDir,
info: {
name: packageName,
version: packageVersion,
moduleSystem: 'esm',
},
})
copyFileSync(
path.join(sourcePackageDir, `api/public.d.ts`),
path.join(sourcePackageDir, 'dist-cjs/index.d.ts')
)
copyFileSync(
path.join(sourcePackageDir, `api/public.d.ts`),
path.join(sourcePackageDir, 'dist-esm/index.d.mts')
)
}
/** This uses esbuild to build the esm version of the package */
async function buildLibrary({
sourceFiles,
sourcePackageDir,
info,
}: {
sourceFiles: string[]
sourcePackageDir: string
info: LibraryInfo
}) {
const dirName = `dist-${info.moduleSystem}`
const outdir = path.join(sourcePackageDir, dirName)
rimraf.sync(outdir)
const define: Record<string, string> = {}
if (process.env.TLDRAW_BEMO_URL) {
define['process.env.TLDRAW_BEMO_URL'] = JSON.stringify(process.env.TLDRAW_BEMO_URL)
}
// we use `globalThis` instead of `process.env` because although it'll get compiled away in
// library code, we use the un-compiled versions in our own apps and not every environment
// supports process.env.
define['globalThis.TLDRAW_LIBRARY_NAME'] = JSON.stringify(info.name)
define['globalThis.TLDRAW_LIBRARY_VERSION'] = JSON.stringify(info.version)
define['globalThis.TLDRAW_LIBRARY_MODULES'] = JSON.stringify(info.moduleSystem)
define['globalThis.TLDRAW_LIBRARY_IS_BUILD'] = JSON.stringify(true)
const res = await build({
entryPoints: sourceFiles,
outdir,
format: info.moduleSystem,
outExtension: info.moduleSystem === 'esm' ? { '.js': '.mjs' } : undefined,
bundle: false,
platform: 'neutral',
sourcemap: true,
target: 'es2022',
define,
})
if (info.moduleSystem === 'esm') {
addJsExtensions(path.join(sourcePackageDir, 'dist-esm'))
}
if (res.errors.length) {
console.error(kleur.red('Build failed with errors:'))
console.error(res.errors)
throw new Error('esm build failed')
}
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
// module was called directly
;(async () => {
await buildPackage({
sourcePackageDir: process.cwd(),
})
})()
}

View File

@@ -0,0 +1,23 @@
import { exec } from './lib/exec'
import { nicelog } from './lib/nicelog'
import { getLatestTldrawVersionFromNpm, setAllVersions } from './lib/publishing'
async function main() {
const latestVersion = await getLatestTldrawVersionFromNpm()
nicelog('Bumping all package versions to', latestVersion.format())
await setAllVersions(latestVersion.format(), { stageChanges: true })
// Commit the changes with [skip ci] to prevent triggering other workflows
await exec('git', ['commit', '-m', `Bump versions to ${latestVersion.format()} [skip ci]`])
// Push the changes
await exec('git', ['push'])
nicelog('Successfully bumped versions and pushed changes')
}
main().catch((error) => {
nicelog('Error:', error)
process.exit(1)
})

View File

@@ -0,0 +1,237 @@
import { execFile } from 'child_process'
import { access, readdir } from 'fs/promises'
import path from 'path'
import { REPO_ROOT } from './lib/file'
import { nicelog } from './lib/nicelog'
const packageEntry = 'src/index.ts'
async function exists(filePath: string) {
try {
await access(filePath)
return true
} catch {
return false
}
}
async function getPackageEntries() {
const packagesDir = path.join(REPO_ROOT, 'packages')
const packageNames = (await readdir(packagesDir)).sort()
const entries: Record<string, string> = {}
for (const packageName of packageNames) {
const entryPath = path.join(packagesDir, packageName, packageEntry)
if (await exists(entryPath)) {
entries[packageName] = entryPath
}
}
return entries
}
async function getIncludePatternSets(packageName: string) {
const fullPackagePatternSet = [[`**/packages/${packageName}/**/*.{js,jsx,ts,tsx}`]]
if (packageName !== 'tldraw') return fullPackagePatternSet
// NOTE:
// `vite-plugin-circular-dependency` crashes with "Invalid array length" on tldraw when
// `ui/components` and `ui/context` are scanned together in a single build graph.
// To keep checks reliable and fast, we build tldraw in a few dynamic batches:
// - all non-ui scopes together
// - ui/components alone
// - ui/context alone
// - remaining ui scopes together
// These batches are discovered from the filesystem to avoid brittle hardcoded scope lists.
const tldrawLibDir = path.join(REPO_ROOT, 'packages', 'tldraw', 'src', 'lib')
const tldrawEntries = (await readdir(tldrawLibDir, { withFileTypes: true })).sort((a, b) =>
a.name.localeCompare(b.name)
)
const nonUiPatterns: string[] = []
let uiComponentsPattern: string | null = null
let uiContextPattern: string | null = null
const uiOtherPatterns: string[] = []
for (const entry of tldrawEntries.filter((entry) => !entry.name.startsWith('.'))) {
if (!entry.isDirectory()) {
if (/\.(ts|tsx|js|jsx)$/.test(entry.name)) {
nonUiPatterns.push(`**/packages/tldraw/src/lib/${entry.name}`)
}
continue
}
if (entry.name !== 'ui') {
nonUiPatterns.push(`**/packages/tldraw/src/lib/${entry.name}/**/*.{js,jsx,ts,tsx}`)
continue
}
const tldrawUiDir = path.join(tldrawLibDir, 'ui')
const tldrawUiEntries = (await readdir(tldrawUiDir, { withFileTypes: true })).sort((a, b) =>
a.name.localeCompare(b.name)
)
for (const uiEntry of tldrawUiEntries.filter((uiEntry) => !uiEntry.name.startsWith('.'))) {
const uiPattern = uiEntry.isDirectory()
? `**/packages/tldraw/src/lib/ui/${uiEntry.name}/**/*.{js,jsx,ts,tsx}`
: /\.(ts|tsx|js|jsx)$/.test(uiEntry.name)
? `**/packages/tldraw/src/lib/ui/${uiEntry.name}`
: null
if (!uiPattern) continue
if (uiEntry.name === 'components') {
uiComponentsPattern = uiPattern
} else if (uiEntry.name === 'context') {
uiContextPattern = uiPattern
} else {
uiOtherPatterns.push(uiPattern)
}
}
}
const includePatternSets = [
['**/packages/tldraw/src/index.ts', ...nonUiPatterns],
...(uiComponentsPattern ? [['**/packages/tldraw/src/index.ts', uiComponentsPattern]] : []),
...(uiContextPattern ? [['**/packages/tldraw/src/index.ts', uiContextPattern]] : []),
...(uiOtherPatterns.length > 0
? [['**/packages/tldraw/src/index.ts', ...uiOtherPatterns]]
: []),
]
return includePatternSets
}
async function checkPackageForCircularDependencies(packageName: string, packageEntryPath: string) {
const includePatternSets = await getIncludePatternSets(packageName)
const script = `
import { build } from 'vite'
import circularDependencyChecker from 'vite-plugin-circular-dependency'
import { mkdirSync, readFileSync, rmSync } from 'fs'
import path from 'path'
const packageName = process.argv[1]
const packageEntryPath = process.argv[2]
const includePatternSets = JSON.parse(process.argv[3])
const maxConcurrency = packageName === 'tldraw' ? 2 : 1
async function checkIncludeSet(includePatterns) {
const outputFilePath = path.join(
'.lazy',
'circle-deps',
packageName + '-' + Date.now() + '-' + Math.random().toString(36).slice(2) + '.json'
)
mkdirSync(path.dirname(outputFilePath), { recursive: true })
let cycleReport = {}
try {
await build({
root: ${JSON.stringify(REPO_ROOT)},
logLevel: 'error',
plugins: [
circularDependencyChecker({
circleImportThrowErr: false,
outputFilePath,
include: includePatterns,
}),
],
build: {
write: false,
emptyOutDir: false,
minify: false,
target: 'es2022',
rollupOptions: {
input: {
[packageName]: packageEntryPath,
},
onwarn(warning, warn) {
if (warning.code === 'MODULE_LEVEL_DIRECTIVE') return
warn(warning)
},
},
},
})
cycleReport = JSON.parse(readFileSync(outputFilePath, 'utf8'))
} finally {
rmSync(outputFilePath, { force: true })
}
const cycleSignatures = Object.values(cycleReport)
.flat()
.map((chain) => chain.join(' -> '))
if (cycleSignatures.length > 0) {
console.error(
'Found circular dependencies:\\n' +
cycleSignatures.map((signature) => ' ' + signature).join('\\n')
)
process.exit(1)
}
}
const queue = [...includePatternSets]
const workers = Array.from({ length: Math.min(maxConcurrency, queue.length) }, async () => {
while (queue.length > 0) {
const includePatterns = queue.shift()
if (!includePatterns) return
await checkIncludeSet(includePatterns)
}
})
await Promise.all(workers)
`
return new Promise<void>((resolve, reject) => {
const child = execFile(
'node',
[
'--input-type=module',
'-e',
script,
packageName,
packageEntryPath,
JSON.stringify(includePatternSets),
],
{ cwd: REPO_ROOT },
(error) => {
if (error) reject(error)
else resolve()
}
)
child.stdout?.on('data', (chunk) => process.stdout.write(chunk))
child.stderr?.on('data', (chunk) => process.stderr.write(chunk))
})
}
async function main() {
const entries = await getPackageEntries()
const packageNames = Object.keys(entries)
nicelog(`Checking for circular dependencies in packages (${packageNames.length}):`)
nicelog(packageNames.join(', '))
const packageCheckResults = await Promise.allSettled(
packageNames.map(async (packageName) => {
const packageEntryPath = entries[packageName]
nicelog(`Checking ${packageName}...`)
await checkPackageForCircularDependencies(packageName, packageEntryPath)
})
)
const failedPackages = packageCheckResults.flatMap((result, index) =>
result.status === 'rejected' ? [packageNames[index]] : []
)
if (failedPackages.length > 0) {
console.error(
`Circular dependency checks failed for ${failedPackages.length} package(s): ${failedPackages.join(', ')}`
)
process.exit(1)
}
nicelog(`No circular dependencies detected across ${packageNames.length} packages.`)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})

View File

@@ -0,0 +1,618 @@
import { existsSync } from 'fs'
import path, { join, relative } from 'path'
import kleur from 'kleur'
import {
REPO_ROOT,
readFileIfExists,
readJsonIfExists,
writeCodeFile,
writeJsonFile,
writeStringFile,
} from './lib/file'
import { nicelog } from './lib/nicelog'
import { PRODUCT_CONFIG_KEY } from './lib/types'
import { Package, getAllWorkspacePackages, getRootPackage } from './lib/workspace'
const packagesWithoutTSConfigs: ReadonlySet<string> = new Set(['config'])
// all packages should have these scripts
const expectedPackageJsonScriptsForAll = {
lint: tsScript('lint.ts'),
}
const expectedTestScripts = {
test: () => 'yarn run -T vitest --passWithNoTests',
'test-ci': () => 'yarn run -T vitest run --passWithNoTests',
'test-coverage': () => 'yarn run -T vitest run --coverage --passWithNoTests',
}
// packages (in packages/) should have these scripts
const expectedPackageJsonScriptsForLibraries = {
...expectedPackageJsonScriptsForAll,
...expectedTestScripts,
}
const expectedPackageJsonScriptsForApps = {
...expectedPackageJsonScriptsForAll,
...expectedTestScripts,
}
// published packages should have these scripts
const expectedPackageJsonScriptsForPublishedLibraries = {
...expectedPackageJsonScriptsForLibraries,
build: tsScript('build-package.ts'),
'build-api': tsScript('build-api.ts'),
prepack: tsScript('prepack.ts'),
postpack: (packageDir: string) => scriptPath(packageDir, 'postpack.sh'),
'pack-tarball': () => 'yarn pack',
}
// individual packages can have different scripts than the above if needed
const packageJsonScriptExceptions: Record<string, Record<string, () => string | undefined>> = {
config: {
lint: () => undefined,
},
tsconfig: {
lint: () => undefined,
},
'@tldraw/monorepo': {
lint: () => undefined,
},
'@tldraw/assets': {
test: () => undefined,
'test-ci': () => undefined,
build: () => undefined,
'build-api': () => undefined,
prepack: () => undefined,
postpack: () => undefined,
},
'create-tldraw': {
build: () => './scripts/build.sh',
'build-api': () => undefined,
prepack: () => 'yarn build',
postpack: () => undefined,
},
}
async function checkPackageJsonScripts({
packages,
fix,
}: {
packages: Package[]
fix: boolean
}): Promise<boolean> {
const needsFix = new Set()
let errorCount = 0
for (const { path: packageDir, relativePath, packageJson, name } of packages) {
if (!packageJson.scripts) {
packageJson.scripts = {}
}
const packageScripts = packageJson.scripts
let expected = relativePath.startsWith('packages/')
? packageJson.private
? expectedPackageJsonScriptsForLibraries
: expectedPackageJsonScriptsForPublishedLibraries
: relativePath.startsWith('apps/')
? expectedPackageJsonScriptsForApps
: expectedPackageJsonScriptsForAll
if (packageJsonScriptExceptions[name]) {
expected = {
...expected,
...packageJsonScriptExceptions[name],
}
}
for (const [scriptName, getExpectedScript] of Object.entries(expected)) {
const actualScript = packageScripts[scriptName]
const expectedScript = getExpectedScript(packageDir)
if (actualScript !== expectedScript) {
nicelog(
[
'❌ ',
kleur.red(`${name}: `),
kleur.blue(`$ yarn ${scriptName}`),
kleur.grey(' -> '),
kleur.red(actualScript ?? '<missing>'),
kleur.gray(' (expected: '),
kleur.green(expectedScript),
kleur.gray(')'),
].join('')
)
packageScripts[scriptName] = expectedScript
needsFix.add(name)
errorCount++
} else {
nicelog(
[
'✅ ',
kleur.green(`${name}: `),
kleur.blue(`$ yarn ${scriptName}`),
kleur.grey(' -> '),
kleur.green(actualScript ?? '<missing>'),
].join('')
)
}
}
}
if (errorCount) {
if (fix) {
for (const { packageJson, name, relativePath } of packages) {
if (needsFix.has(name)) {
nicelog(kleur.yellow(`Fixing ${name}...`))
await writeJsonFile(path.join(REPO_ROOT, relativePath, 'package.json'), packageJson)
}
}
nicelog(kleur.yellow(`Fixed ${errorCount} errors`))
return true
} else {
nicelog(kleur.red(`Found ${errorCount} errors`))
return false
}
}
return true
}
async function checkTsConfigs({
packages,
fix,
}: {
fix: boolean
packages: Package[]
}): Promise<boolean> {
let numErrors = 0
for (const workspace of packages) {
const tsconfigPath = join(workspace.path, 'tsconfig.json')
if (packagesWithoutTSConfigs.has(workspace.name)) {
continue
}
const tsconfig = (await readJsonIfExists(tsconfigPath)) as {
references?: { path: string }[]
}
if (!tsconfig) {
throw new Error('No tsconfig.json found at ' + tsconfigPath)
}
const tldrawDeps = Object.keys({
...workspace.packageJson.dependencies,
...workspace.packageJson.devDependencies,
}).filter((dep) => packages.some((p) => p.name === dep))
const fixedDeps = []
const missingRefs = []
const currentRefs = new Set<string>([...(tsconfig.references?.map((ref) => ref.path) ?? [])])
for (const dep of tldrawDeps) {
// construct the expected path to the dependency's tsconfig
const matchingWorkspace = packages.find((p) => p.name === dep)
if (!matchingWorkspace) {
throw new Error(`No workspace found for ${dep}`)
}
const tsconfigReferencePath = relative(workspace.path, matchingWorkspace.path)
fixedDeps.push({ path: tsconfigReferencePath })
if (currentRefs.has(tsconfigReferencePath)) {
currentRefs.delete(tsconfigReferencePath)
} else {
missingRefs.push(dep)
}
}
fixedDeps.sort((a, b) => a.path.localeCompare(b.path))
if (currentRefs.size > 0) {
if (fix) {
tsconfig.references = fixedDeps
await writeJsonFile(tsconfigPath, tsconfig)
} else {
numErrors++
nicelog(
[
'❌ ',
kleur.red(`${workspace.name}: `),
kleur.blue(relative(process.cwd(), tsconfigPath)),
kleur.grey(' has unnecessary reference(s) to '),
kleur.red([...currentRefs].join(', ')),
].join('')
)
}
}
if (missingRefs.length) {
if (fix) {
tsconfig.references = fixedDeps
await writeJsonFile(tsconfigPath, tsconfig)
} else {
numErrors++
nicelog(
[
'❌ ',
kleur.red(`${workspace.name}: `),
kleur.blue(relative(process.cwd(), tsconfigPath)),
kleur.grey(' is missing reference(s) to '),
kleur.red(missingRefs.join(', ')),
].join('')
)
nicelog('The references entry should look like this:')
nicelog('"references": ' + JSON.stringify(fixedDeps, null, 2))
}
}
if (currentRefs.size === 0 && missingRefs.length === 0) {
nicelog(['✅ ', kleur.green(`${workspace.name}`)].join(''))
}
}
if (numErrors > 0) {
nicelog('Run `yarn check-tsconfigs --fix` to fix these problems')
return false
}
return true
}
function scriptPath(packageDir: string, scriptName: string) {
return path.relative(packageDir, path.join(__dirname, scriptName))
}
function tsScript(scriptName: string) {
return (packageDir: string) => `yarn run -T tsx ${scriptPath(packageDir, scriptName)}`
}
async function checkLibraryContents({
fix,
packages,
}: {
fix: boolean
packages: Package[]
}): Promise<boolean> {
let errorCount = 0
for (const { packageJson, name, path } of packages) {
if (packageJson.private) continue
const sourceFilePath = join(path, 'src', 'index.ts')
const sourceFileContents = await readFileIfExists(sourceFilePath)
if (!sourceFileContents) {
nicelog(['⏩ ', kleur.blue(`${name}: `), 'skipped (no src/index.ts)'].join(''))
continue
}
const search = [
'registerTldrawLibraryVersion(',
'\t(globalThis as any).TLDRAW_LIBRARY_NAME,',
'\t(globalThis as any).TLDRAW_LIBRARY_VERSION,',
'\t(globalThis as any).TLDRAW_LIBRARY_MODULES',
')',
].join('\n')
if (sourceFileContents.includes(search)) {
nicelog(['✅ ', kleur.green(name)].join(''))
continue
}
errorCount++
if (fix) {
const newSourceFileContents = [
"import {registerTldrawLibraryVersion} from '@tldraw/utils'",
sourceFileContents,
'',
'registerTldrawLibraryVersion(',
'(globalThis as any).TLDRAW_LIBRARY_NAME,',
'(globalThis as any).TLDRAW_LIBRARY_VERSION,',
'(globalThis as any).TLDRAW_LIBRARY_NAME',
')',
].join('\n')
await writeCodeFile(null, 'typescript', sourceFilePath, newSourceFileContents)
nicelog(
[
'⚠️ ',
kleur.yellow(`${name}: `),
'added call to ',
kleur.blue('registerTldrawLibraryVersion'),
].join('')
)
} else {
nicelog(
[
'❌ ',
kleur.red(`${name}: `),
'missing call to ',
kleur.blue('registerTldrawLibraryVersion'),
].join('')
)
}
}
if (errorCount) {
if (fix) {
nicelog(kleur.yellow(`Fixed ${errorCount} errors`))
return true
} else {
nicelog(kleur.red(`Found ${errorCount} errors`))
return false
}
}
return true
}
const LICENSE_POINTER_CONTENTS =
'This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)\n'
// The stable id convention is documented in internal/docs/product-stable-ids.md. Every published
// package in packages/ must declare which commercial component it belongs to, and premium
// components must name the license key flag that entitles them.
async function checkProductMetadata({
packages,
fix,
}: {
packages: Package[]
fix: boolean
}): Promise<boolean> {
let errorCount = 0
const error = (name: string, message: string) => {
errorCount++
nicelog(['❌ ', kleur.red(`${name}: `), message].join(''))
}
const licenseManagerSource = await readFileIfExists(
join(REPO_ROOT, 'packages/editor/src/lib/license/LicenseManager.ts')
)
if (!licenseManagerSource) {
throw new Error('Could not read LicenseManager.ts to validate license flags')
}
const knownLicenseFlags = new Set(
[...licenseManagerSource.matchAll(/^\t(FEAT_[A-Z0-9_]+):/gm)].map((m) => m[1])
)
const byStableId = new Map<string, { name: string; config: string }[]>()
for (const { packageJson, name, relativePath, path: packageDir } of packages) {
if (!relativePath.startsWith('packages/') || packageJson.private) continue
// published packages that point at LICENSE.md must actually ship one, since npm only
// includes license files that exist inside the package directory
if (packageJson.license === 'SEE LICENSE IN LICENSE.md') {
const licensePath = join(packageDir, 'LICENSE.md')
if (!existsSync(licensePath)) {
if (fix) {
await writeStringFile(licensePath, LICENSE_POINTER_CONTENTS)
nicelog(['⚠️ ', kleur.yellow(`${name}: `), 'added missing LICENSE.md'].join(''))
} else {
error(name, 'is missing LICENSE.md (run `yarn check-packages --fix`)')
}
}
}
const product = packageJson[PRODUCT_CONFIG_KEY]
if (!product) {
error(
name,
`is missing the "${PRODUCT_CONFIG_KEY}" field in package.json. ` +
'See internal/docs/product-stable-ids.md for how to choose a stable id.'
)
continue
}
if (!/^tldraw:[a-z0-9-]+$/.test(product.stableId)) {
error(name, `has invalid stableId "${product.stableId}" (expected tldraw:<kebab-case>)`)
}
if (product.premium && !product.licenseFlag) {
error(name, 'is premium but has no licenseFlag')
}
if (!product.premium && product.licenseFlag) {
error(name, 'has a licenseFlag but is not premium')
}
if (product.licenseFlag && !knownLicenseFlags.has(product.licenseFlag)) {
error(
name,
`has licenseFlag "${product.licenseFlag}" which does not exist in LicenseManager FLAGS`
)
}
if (product.type === 'feature' && !product.parent) {
error(name, 'is a feature but has no parent component')
}
if (product.type === 'product' && product.parent) {
error(name, 'is a top-level product but declares a parent')
}
if (product.parent === product.stableId) {
error(name, 'is its own parent')
}
// all packages sharing a stable id must agree on the rest of the metadata, since they
// describe the same commercial component
const configKey = JSON.stringify([
product.name,
product.type,
product.parent ?? null,
product.premium,
product.licenseFlag ?? null,
])
const others = byStableId.get(product.stableId) ?? []
others.push({ name, config: configKey })
byStableId.set(product.stableId, others)
}
for (const [stableId, entries] of byStableId) {
if (new Set(entries.map((e) => e.config)).size > 1) {
error(
stableId,
`packages disagree on product metadata: ${entries.map((e) => e.name).join(', ')}`
)
}
}
// every parent must be a component that actually exists, or the order form would reference a
// line item nothing defines
for (const { packageJson, name, relativePath } of packages) {
if (!relativePath.startsWith('packages/') || packageJson.private) continue
const parent = packageJson[PRODUCT_CONFIG_KEY]?.parent
if (parent && !byStableId.has(parent)) {
error(name, `has parent "${parent}" which is not a known component`)
}
}
if (errorCount) {
nicelog(kleur.red(`Found ${errorCount} errors`))
return false
}
nicelog(['✅ ', kleur.green('product metadata ok')].join(''))
return true
}
async function group<T>(name: string, cb: () => Promise<T>) {
console.group(name)
try {
return await cb()
} finally {
console.groupEnd()
console.log('')
}
}
// Every workspace uses the same version range for a dependency, so the lockfile resolves one
// copy. To let a dependency diverge, list the workspaces that step off the shared range here:
// they must agree with each other, and everything else must agree with everything else.
const ALLOWED_VERSION_DIVERGENCE: Record<string, { workspaces: string[]; reason: string }> = {
typescript: {
workspaces: ['templates/', 'apps/mcp-app'],
reason:
"templates are independently published starters, and mcp-app's extract-editor-api.ts " +
"needs the TS 5 compiler API that TS 7 doesn't export",
},
}
// Node 22.12 is the first version where `require()` of an ES module works unflagged, so
// published packages can depend on ESM-only modules without breaking CommonJS consumers.
// Earlier versions throw `ERR_REQUIRE_ESM`.
const PUBLISHED_NODE_ENGINE = '>=22.12.0'
const DEPENDENCY_FIELDS = [
'dependencies',
'devDependencies',
'optionalDependencies',
'peerDependencies',
] as const
type DependencyField = (typeof DEPENDENCY_FIELDS)[number]
async function checkDependencyVersions({
packages,
fix,
}: {
packages: Package[]
fix: boolean
}): Promise<boolean> {
let errorCount = 0
const changed = new Set<Package>()
const report = (name: string, message: string) => {
errorCount++
nicelog([fix ? '⚠️ ' : '❌ ', (fix ? kleur.yellow : kleur.red)(`${name}: `), message].join(''))
}
const root = await getRootPackage()
// Peer ranges are deliberately wider than installed ranges, so they're only compared with
// each other.
const usages = new Map<string, { pkg: Package; field: DependencyField; range: string }[]>()
for (const pkg of [root, ...packages]) {
for (const field of DEPENDENCY_FIELDS) {
for (const [dep, range] of Object.entries(pkg.packageJson[field] ?? {})) {
const diverges = ALLOWED_VERSION_DIVERGENCE[dep]?.workspaces.some((prefix) =>
pkg.relativePath.startsWith(prefix)
)
const kind = field === 'peerDependencies' ? 'peer' : 'installed'
const key = `${dep}\0${kind}\0${diverges ? 'diverged' : 'shared'}`
if (!usages.has(key)) usages.set(key, [])
usages.get(key)!.push({ pkg, field, range })
}
}
}
for (const [key, group] of usages) {
const counts = new Map<string, number>()
for (const { range } of group) counts.set(range, (counts.get(range) ?? 0) + 1)
if (counts.size === 1) continue
const dep = key.split('\0')[0]
const [expected] = [...counts].sort((a, b) => b[1] - a[1])[0]
for (const { pkg, field, range } of group) {
if (range === expected) continue
report(pkg.name, `${field}.${dep} is ${range}, but other workspaces use ${expected}`)
if (fix) {
pkg.packageJson[field]![dep] = expected
changed.add(pkg)
}
}
}
for (const pkg of packages) {
if ('packageManager' in pkg.packageJson) {
report(pkg.name, 'only the root package.json may set packageManager')
if (fix) {
delete pkg.packageJson.packageManager
changed.add(pkg)
}
}
if (!pkg.relativePath.startsWith('packages/')) continue
const engines = pkg.packageJson.engines ?? {}
if (engines.node !== PUBLISHED_NODE_ENGINE) {
report(pkg.name, `engines.node must be ${PUBLISHED_NODE_ENGINE}`)
if (fix) {
pkg.packageJson.engines = { ...engines, node: PUBLISHED_NODE_ENGINE }
changed.add(pkg)
}
}
}
for (const pkg of changed) {
await writeJsonFile(join(pkg.path, 'package.json'), pkg.packageJson)
}
if (errorCount) {
nicelog(
fix
? kleur.yellow(`Fixed ${errorCount} errors. Run \`yarn\` to update the lockfile.`)
: kleur.red(`Found ${errorCount} errors. Run \`yarn check-packages --fix\` to fix them.`)
)
return fix
}
nicelog(' ✅ dependency versions ok')
return true
}
async function main({ fix }: { fix: boolean }) {
const packages = await getAllWorkspacePackages()
const scriptsOk = await group('Checking package.json scripts...', () =>
checkPackageJsonScripts({ packages, fix })
)
const tsConfigsOk = await group('Checking tsconfig.json files...', () =>
checkTsConfigs({ packages, fix })
)
const libsOk = await group('Checking library source files...', () =>
checkLibraryContents({ packages, fix })
)
const productMetadataOk = await group('Checking product metadata...', () =>
checkProductMetadata({ packages, fix })
)
const dependencyVersionsOk = await group('Checking dependency versions...', () =>
checkDependencyVersions({ packages, fix })
)
if (!scriptsOk || !tsConfigsOk || !libsOk || !productMetadataOk || !dependencyVersionsOk) {
process.exit(1)
}
}
main({
fix: process.argv.includes('--fix'),
})

View File

@@ -0,0 +1,65 @@
import { existsSync, readFileSync, rmSync } from 'fs'
import { resolve } from 'path'
import kleur from 'kleur'
import minimist from 'minimist'
import { exec } from './lib/exec'
import { WORKER_EXTERNAL_DEPS } from './lib/worker-externals'
const args = minimist(process.argv.slice(2))
const sizeLimit = Number(args['size-limit-bytes'])
if (!isFinite(sizeLimit) || sizeLimit < 1) {
console.error(
'Invalid usage. Usage: yarn tsx check-worker-bundle.ts --size-limit-bytes <size> --entry <entry>'
)
process.exit(1)
}
const entry = args['entry']
if (!existsSync(entry)) {
console.error('Entry file does not exist:', entry)
process.exit(1)
}
const bundleMetaFileName = '.bundle-meta.json'
interface Meta {
outputs: {
[fileName: string]: {
bytes: number
}
}
}
async function checkBundleSize() {
rmSync(bundleMetaFileName, { force: true })
console.log('checking bundle size')
await exec('yarn', [
'esbuild',
entry,
'--bundle',
'--outfile=/dev/null',
'--minify',
'--metafile=' + bundleMetaFileName,
...WORKER_EXTERNAL_DEPS.map((dep) => '--external:' + dep),
])
console.log(kleur.cyan().bold('Checking bundle size'), 'of', resolve(entry) + '\n')
const meta = JSON.parse(readFileSync(bundleMetaFileName).toString()) as Meta
const totalSize = Object.values(meta.outputs).reduce((acc, output) => acc + output.bytes, 0)
if (totalSize === 0) {
throw new Error('Invalid bundle meta in ' + process.cwd() + '/' + bundleMetaFileName)
}
if (totalSize > sizeLimit) {
console.error(
`${kleur.red().bold('ERROR')} Bundle size exceeds limit: ${totalSize} > ${sizeLimit}`
)
process.exit(1)
}
console.log(`Bundle size is within limit: ${totalSize} < ${sizeLimit}`)
}
checkBundleSize()

41
internal/scripts/clean.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -eux
yarn run lazy run clean || true
# a function called 'goodbye' that takes a string as an argument
function goodbye() {
rm -rf $1
rm -rf */$1
rm -rf */*/$1
rm -rf */*/*/$1
rm -rf */*/*/*/$1
rm -rf */*/*/*/*/$1
rm -rf **/$1
}
rm -rf node_modules
goodbye node_modules
goodbye .turbo
goodbye tmp-assets
goodbye tsconfig.tsbuildinfo
goodbye tsconfig.build.tsbuildinfo
goodbye tsconfig.build.json
goodbye .lazy
goodbye dist-esm
goodbye dist-cjs
goodbye .tsbuild
goodbye .tsbuild-pub
goodbye .tsbuild-dev
goodbye .tsbuild-api
goodbye .next
rm -rf packages/*/api
rm -rf {packages,apps}/*/*.tgz
rm -rf {packages,apps}/vscode/extension/temp
rm -rf {packages,apps}/vscode/extension/editor
rm -rf apps/docs/content.json
rm -rf apps/dotcom/client/e2e/.auth
npm i -g corepack
yarn

View File

@@ -0,0 +1,104 @@
import { execFileSync } from 'child_process'
import { makeEnv } from '../lib/makeEnv'
import { nicelog } from '../lib/nicelog'
// Check DNS records in a zone: what's there already, and which pr-NNNN-* records are orphaned.
// Read-only. Usage: CLOUDFLARE_API_TOKEN=xxx yarn tsx internal/scripts/cloudflare/dns-check.ts <zone_name>
// Token needs "Zone: Read" + "DNS: Read"; gh CLI must be authed for tldraw/tldraw.
const env = makeEnv(['CLOUDFLARE_API_TOKEN'])
const [zoneName] = process.argv.slice(2)
if (!zoneName) {
nicelog('usage: dns-check.ts <zone_name>')
process.exit(1)
}
const API = 'https://api.cloudflare.com/client/v4'
async function cfApi<T>(endpoint: string): Promise<T> {
const res = await fetch(`${API}${endpoint}`, {
headers: { Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}` },
})
if (!res.ok) throw new Error(`GET ${endpoint}: ${res.status} ${res.statusText}`)
const data = (await res.json()) as { success: boolean; errors: unknown; result: T }
if (!data.success) throw new Error(`GET ${endpoint}: ${JSON.stringify(data.errors)}`)
return data.result
}
const prStateCache = new Map<string, string>()
function prState(pr: string): string {
let state = prStateCache.get(pr)
if (!state) {
try {
state = execFileSync('gh', ['api', `repos/tldraw/tldraw/pulls/${pr}`, '--jq', '.state'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim()
} catch (err: any) {
if (String(err.stderr).includes('HTTP 404')) {
state = 'closed'
} else {
throw new Error(`gh api failed for PR #${pr}: ${err.stderr ?? err}`)
}
}
prStateCache.set(pr, state)
}
return state
}
interface DnsRecord {
type: string
name: string
content: string
proxied: boolean
}
async function main() {
const zones = await cfApi<{ id: string }[]>(`/zones?name=${zoneName}`)
if (!zones[0]) throw new Error(`zone not found: ${zoneName}`)
const zoneId = zones[0].id
const records: DnsRecord[] = []
for (let page = 1; ; page++) {
const result = await cfApi<DnsRecord[]>(
`/zones/${zoneId}/dns_records?per_page=100&page=${page}`
)
records.push(...result)
if (result.length < 100) break
}
const fmt = (r: DnsRecord) =>
`${r.type.padEnd(6)} ${r.name.padEnd(36)} ${String(r.content).slice(0, 50).padEnd(52)} proxied=${r.proxied}`
const isPreview = (r: DnsRecord) => /^pr-\d+-/.test(r.name)
nicelog('=== non-PR records (existing infra) ===')
for (const r of records
.filter((r) => !isPreview(r))
.sort((a, b) => a.name.localeCompare(b.name))) {
nicelog(fmt(r))
}
nicelog('\n=== PR preview records ===')
let ok = 0
let orphaned = 0
for (const r of records.filter(isPreview)) {
const pr = r.name.match(/^pr-(\d+)-/)![1]
if (prState(pr) === 'closed') {
nicelog(`ORPHAN (PR #${pr} closed) ${fmt(r)}`)
orphaned++
} else {
nicelog(`ok (PR #${pr} open) ${fmt(r)}`)
ok++
}
}
nicelog('\n=== summary ===')
nicelog(`total records: ${records.length}`)
nicelog(`pr records: ok=${ok} orphaned=${orphaned}`)
const hasWildcard = records.some((r) => r.name === `*.${zoneName}`)
nicelog(`wildcard *.${zoneName}: ${hasWildcard ? 'EXISTS' : 'not present'}`)
}
main()

View File

@@ -0,0 +1,142 @@
import { execFileSync } from 'child_process'
import { makeEnv } from '../lib/makeEnv'
import { nicelog } from '../lib/nicelog'
// Delete orphaned per-PR advanced cert packs (pr-NNNN-* hostnames) for CLOSED PRs only.
// Usage: CLOUDFLARE_API_TOKEN=xxx yarn tsx internal/scripts/cloudflare/ssl-cleanup.ts <zone_name> [--delete]
// Default is dry-run. Token needs "Zone: Read" + "SSL and Certificates: Edit";
// gh CLI must be authed for tldraw/tldraw.
const env = makeEnv(['CLOUDFLARE_API_TOKEN'])
const [zoneName, mode] = process.argv.slice(2)
if (!zoneName || (mode && mode !== '--delete')) {
nicelog('usage: ssl-cleanup.ts <zone_name> [--delete]')
process.exit(1)
}
const doDelete = mode === '--delete'
const API = 'https://api.cloudflare.com/client/v4'
async function cfApi<T>(endpoint: string, options: RequestInit = {}, attempt = 0): Promise<T> {
const res = await fetch(`${API}${endpoint}`, {
...options,
headers: { Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}` },
})
if (res.status === 429 && attempt < 3) {
const waitSeconds = Number(res.headers.get('retry-after')) || 10
nicelog(`rate limited, retrying in ${waitSeconds}s...`)
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000))
return cfApi(endpoint, options, attempt + 1)
}
if (!res.ok) {
throw new Error(`${options.method ?? 'GET'} ${endpoint}: ${res.status} ${res.statusText}`)
}
const data = (await res.json()) as { success: boolean; errors: unknown; result: T }
if (!data.success) {
throw new Error(`${options.method ?? 'GET'} ${endpoint}: ${JSON.stringify(data.errors)}`)
}
return data.result
}
// Only an HTTP 404 (deleted PR) counts as closed; any other gh failure throws
// so a broken token can't authorize deleting open-PR packs. Uses the gh CLI so
// local runs reuse the developer's existing auth.
const prStateCache = new Map<string, string>()
function prState(pr: string): string {
let state = prStateCache.get(pr)
if (!state) {
try {
state = execFileSync('gh', ['api', `repos/tldraw/tldraw/pulls/${pr}`, '--jq', '.state'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim()
} catch (err: any) {
if (String(err.stderr).includes('HTTP 404')) {
state = 'closed'
} else {
throw new Error(`gh api failed for PR #${pr}: ${err.stderr ?? err}`)
}
}
prStateCache.set(pr, state)
}
return state
}
interface CertPack {
id: string
type: string
status: string
hosts: string[]
}
function isPreviewPack(p: CertPack) {
// status=all also returns packs that are already deleted/pending deletion, skip those
return (
p.type === 'advanced' &&
p.status !== 'deleted' &&
p.status !== 'pending_deletion' &&
p.hosts.some((h) => /^pr-\d+-/.test(h))
)
}
async function main() {
const zones = await cfApi<{ id: string }[]>(`/zones?name=${zoneName}`)
if (!zones[0]) throw new Error(`zone not found: ${zoneName}`)
const zoneId = zones[0].id
let deleted = 0
let kept = 0
let failed = 0
// Process one page at a time instead of fetching everything up front, so an
// abort partway through (rate limit, network) loses nothing already deleted
// and a rerun picks up where it left off. Deleting shifts later items into
// the current page, so after a page with deletions we refetch the same page
// (skipping packs we've already handled) and only advance once it yields
// nothing new. The endpoint caps per_page at 50.
const processed = new Set<string>()
let page = 1
while (true) {
const result = await cfApi<CertPack[]>(
`/zones/${zoneId}/ssl/certificate_packs?status=all&per_page=50&page=${page}`
)
let deletedThisPage = 0
for (const pack of result.filter(isPreviewPack)) {
if (processed.has(pack.id)) continue
processed.add(pack.id)
const pr = pack.hosts.find((h) => /^pr-\d+-/.test(h))!.match(/^pr-(\d+)-/)![1]
const hosts = pack.hosts.join(',')
if (prState(pr) !== 'closed') {
nicelog(`keeping (PR #${pr} open) ${hosts}`)
kept++
continue
}
if (doDelete) {
try {
await cfApi(`/zones/${zoneId}/ssl/certificate_packs/${pack.id}`, { method: 'DELETE' })
nicelog(`deleted (PR #${pr} closed) ${pack.id} ${hosts}`)
deleted++
deletedThisPage++
} catch (err) {
nicelog(`FAILED to delete ${pack.id} ${hosts}: ${err}`)
failed++
}
} else {
nicelog(`would delete (PR #${pr} closed) ${pack.id} ${hosts}`)
deleted++
}
}
if (result.length < 50) break
if (deletedThisPage === 0) page++
}
nicelog('---')
nicelog(
`${deleted} closed-PR packs ${doDelete ? 'deleted' : 'to delete'}, ${kept} open-PR packs kept, ${failed} failed`
)
if (!doDelete) nicelog('(dry run — rerun with --delete to remove)')
if (failed > 0) process.exit(1)
}
main()

View File

@@ -0,0 +1,220 @@
#!/usr/bin/env tsx
/**
* Creates a reference document for Pandoc with custom styling:
* - Times New Roman for all prose
* - Consolas for all code
* - Tighter margins and spacing for density
*/
import { writeFileSync } from 'fs'
import { dirname, resolve } from 'path'
import {
convertInchesToTwip,
Document,
HeadingLevel,
Packer,
PageOrientation,
Paragraph,
TextRun,
} from 'docx'
import { nicelog } from './lib/nicelog'
const DOCS_DIR = resolve(dirname(dirname(dirname(__filename))), 'documentation')
const REFERENCE_FILE = resolve(DOCS_DIR, 'reference.docx')
// Font settings
const BODY_FONT = 'Times New Roman'
const CODE_FONT = 'Consolas'
const BODY_SIZE = 22 // 11pt in half-points
const CODE_SIZE = 20 // 10pt in half-points
const HEADING1_SIZE = 32 // 16pt
const HEADING2_SIZE = 28 // 14pt
const HEADING3_SIZE = 24 // 12pt
async function main() {
nicelog('📄 Creating reference document for Pandoc...')
const doc = new Document({
styles: {
default: {
document: {
run: {
font: BODY_FONT,
size: BODY_SIZE,
},
paragraph: {
spacing: {
after: 120, // 6pt after paragraphs
line: 240, // Single line spacing (240 twips = 1 line)
},
},
},
heading1: {
run: {
font: BODY_FONT,
size: HEADING1_SIZE,
bold: true,
},
paragraph: {
spacing: {
before: 240,
after: 120,
},
},
},
heading2: {
run: {
font: BODY_FONT,
size: HEADING2_SIZE,
bold: true,
},
paragraph: {
spacing: {
before: 200,
after: 100,
},
},
},
heading3: {
run: {
font: BODY_FONT,
size: HEADING3_SIZE,
bold: true,
},
paragraph: {
spacing: {
before: 160,
after: 80,
},
},
},
heading4: {
run: {
font: BODY_FONT,
size: BODY_SIZE,
bold: true,
},
paragraph: {
spacing: {
before: 120,
after: 60,
},
},
},
},
paragraphStyles: [
{
id: 'Normal',
name: 'Normal',
run: {
font: BODY_FONT,
size: BODY_SIZE,
},
paragraph: {
spacing: {
after: 120,
line: 240,
},
},
},
{
id: 'SourceCode',
name: 'Source Code',
basedOn: 'Normal',
run: {
font: CODE_FONT,
size: CODE_SIZE,
},
paragraph: {
spacing: {
after: 0,
line: 220,
},
},
},
{
id: 'VerbatimChar',
name: 'Verbatim Char',
basedOn: 'Normal',
run: {
font: CODE_FONT,
size: CODE_SIZE,
},
},
],
characterStyles: [
{
id: 'VerbatimChar',
name: 'Verbatim Char',
run: {
font: CODE_FONT,
size: CODE_SIZE,
},
},
],
},
sections: [
{
properties: {
page: {
margin: {
top: convertInchesToTwip(0.75),
right: convertInchesToTwip(0.75),
bottom: convertInchesToTwip(0.75),
left: convertInchesToTwip(0.75),
},
size: {
orientation: PageOrientation.PORTRAIT,
},
},
},
children: [
// Sample content to establish styles
new Paragraph({
text: 'Heading 1',
heading: HeadingLevel.HEADING_1,
}),
new Paragraph({
text: 'Heading 2',
heading: HeadingLevel.HEADING_2,
}),
new Paragraph({
text: 'Heading 3',
heading: HeadingLevel.HEADING_3,
}),
new Paragraph({
children: [
new TextRun({
text: 'Normal body text in Times New Roman.',
font: BODY_FONT,
size: BODY_SIZE,
}),
],
}),
new Paragraph({
children: [
new TextRun({
text: 'const code = "Consolas font";',
font: CODE_FONT,
size: CODE_SIZE,
}),
],
style: 'SourceCode',
}),
],
},
],
})
const buffer = await Packer.toBuffer(doc)
writeFileSync(REFERENCE_FILE, buffer)
nicelog(`✅ Created: ${REFERENCE_FILE}`)
nicelog(' Use with: pandoc --reference-doc=documentation/reference.docx')
}
main().catch((error) => {
nicelog(`❌ Failed: ${error}`)
process.exit(1)
})

View File

@@ -0,0 +1,110 @@
import assert from 'assert'
import { readFileSync } from 'fs'
import path, { join } from 'path'
import toml from 'toml'
import {
createGithubDeployment,
getDeployInfo,
setWranglerPreviewConfig,
wranglerDeploy,
} from './lib/deploy'
import { Discord } from './lib/discord'
import { REPO_ROOT } from './lib/file'
import { makeEnv } from './lib/makeEnv'
import { nicelog } from './lib/nicelog'
const workerDir = path.relative(process.cwd(), path.resolve(REPO_ROOT, './apps/analytics-worker'))
// Do not use `process.env` directly in this script. Add your variable to `makeEnv` and use it via
// `env` instead. This makes sure that all required env vars are present.
const env = makeEnv([
'CLOUDFLARE_ACCOUNT_ID',
'CLOUDFLARE_API_TOKEN',
'DISCORD_DEPLOY_WEBHOOK_URL',
'RELEASE_COMMIT_HASH',
'TLDRAW_ENV',
'GH_TOKEN',
])
const discord = new Discord({
webhookUrl: env.DISCORD_DEPLOY_WEBHOOK_URL,
shouldNotify: env.TLDRAW_ENV === 'production',
totalSteps: 2,
messagePrefix: '[ANALYTICS]',
secretValues: Object.values(env),
})
const { previewId, sha } = getDeployInfo()
async function main() {
assert(
env.TLDRAW_ENV === 'staging' || env.TLDRAW_ENV === 'production' || env.TLDRAW_ENV === 'preview',
'TLDRAW_ENV must be staging or production or preview'
)
await discord.message(`--- **${env.TLDRAW_ENV} analytics worker deploy pre-flight** ---`)
await discord.step('cloudflare deploy dry run', async () => {
await deployAnalyticsWorker({ dryRun: true })
})
// --- point of no return! do the deploy for real --- //
await discord.message(`--- **pre-flight complete, starting real analytics worker deploy** ---`)
await discord.step('deploying analytics worker to cloudflare', async () => {
await deployAnalyticsWorker({ dryRun: false })
})
// Get the worker name from wrangler.toml
const wranglerConfig = toml.parse(readFileSync(join(workerDir, 'wrangler.toml')).toString())
const workerName = wranglerConfig?.env?.[env.TLDRAW_ENV]?.name || wranglerConfig?.name
if (!workerName) {
throw new Error('Could not find worker name in wrangler.toml')
}
// Analytics worker uses default workers.dev domain
const deploymentUrl = `https://${workerName}.${env.CLOUDFLARE_ACCOUNT_ID}.workers.dev`
nicelog('Creating deployment for', deploymentUrl)
await createGithubDeployment(env, {
app: 'analytics-worker',
deploymentUrl,
sha,
})
await discord.message(`**Deploy complete!**`)
}
let didUpdateAnalyticsWorker = false
async function deployAnalyticsWorker({ dryRun }: { dryRun: boolean }) {
const workerId = `${previewId ?? env.TLDRAW_ENV}-tldraw-consent`
if (previewId && !didUpdateAnalyticsWorker) {
await setWranglerPreviewConfig(workerDir, {
name: workerId,
routeHostname: `${previewId}-consent.tldraw.xyz`,
})
didUpdateAnalyticsWorker = true
}
await wranglerDeploy({
location: workerDir,
dryRun,
env: env.TLDRAW_ENV,
vars: {
WORKER_NAME: workerId,
TLDRAW_ENV: env.TLDRAW_ENV,
},
})
}
main().catch(async (err) => {
// don't notify discord on preview builds
if (env.TLDRAW_ENV !== 'preview') {
await discord.message(`${Discord.AT_TEAM_MENTION} Deploy failed: ${err.stack}`, {
always: true,
})
}
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,123 @@
import assert from 'assert'
import { readFileSync } from 'fs'
import path, { join } from 'path'
import toml from 'toml'
import {
createGithubDeployment,
getDeployInfo,
setWranglerPreviewConfig,
wranglerDeploy,
} from './lib/deploy'
import { Discord } from './lib/discord'
import { exec } from './lib/exec'
import { REPO_ROOT } from './lib/file'
import { makeEnv } from './lib/makeEnv'
import { nicelog } from './lib/nicelog'
const workerDir = path.relative(process.cwd(), path.resolve(REPO_ROOT, './apps/bemo-worker'))
// Do not use `process.env` directly in this script. Add your variable to `makeEnv` and use it via
// `env` instead. This makes sure that all required env vars are present.
const env = makeEnv([
'CLOUDFLARE_ACCOUNT_ID',
'CLOUDFLARE_API_TOKEN',
'DISCORD_DEPLOY_WEBHOOK_URL',
'RELEASE_COMMIT_HASH',
'TLDRAW_ENV',
'GH_TOKEN',
'SENTRY_AUTH_TOKEN',
'SENTRY_BEMO_WORKER_DSN',
])
const discord = new Discord({
webhookUrl: env.DISCORD_DEPLOY_WEBHOOK_URL,
shouldNotify: env.TLDRAW_ENV === 'production',
totalSteps: 3,
secretValues: Object.values(env),
})
const { previewId, sha } = getDeployInfo()
async function main() {
assert(
env.TLDRAW_ENV === 'staging' || env.TLDRAW_ENV === 'production' || env.TLDRAW_ENV === 'preview',
'TLDRAW_ENV must be staging or production or preview'
)
await discord.message(`--- **${env.TLDRAW_ENV} bemo deploy pre-flight** ---`)
await discord.step('setting up deploy', async () => {
await exec('yarn', ['lazy', 'prebuild'])
})
await discord.step('cloudflare deploy dry run', async () => {
await deployBemoWorker({ dryRun: true })
})
// --- point of no return! do the deploy for real --- //
await discord.message(`--- **pre-flight complete, starting real bemo deploy** ---`)
// 2. deploy the cloudflare workers:
await discord.step('deploying bemo-worker to cloudflare', async () => {
await deployBemoWorker({ dryRun: false })
})
// the deploy hostname comes from the env's route pattern in wrangler.toml
const routePattern = toml.parse(readFileSync(join(workerDir, 'wrangler.toml')).toString())?.env[
env.TLDRAW_ENV
]?.routes?.[0]?.pattern
if (!routePattern) {
throw new Error('Could not find the route pattern in wrangler.toml')
}
// preview route patterns carry a path glob ("host/*"); the deployment URL wants just the host
const deploymentUrl = `https://${routePattern.split('/')[0]}`
nicelog('Creating deployment for', deploymentUrl)
await createGithubDeployment(env, {
app: 'bemo',
deploymentUrl,
sha,
})
await discord.message(`**Deploy complete!**`)
}
let didUpdateBemoWorker = false
async function deployBemoWorker({ dryRun }: { dryRun: boolean }) {
const workerId = `${previewId ?? env.TLDRAW_ENV}-bemo`
if (previewId && !didUpdateBemoWorker) {
await setWranglerPreviewConfig(workerDir, {
name: workerId,
routeHostname: `${previewId}-demo.tldraw.xyz`,
})
didUpdateBemoWorker = true
}
await wranglerDeploy({
location: workerDir,
dryRun,
env: env.TLDRAW_ENV,
vars: {
WORKER_NAME: workerId,
TLDRAW_ENV: env.TLDRAW_ENV,
SENTRY_DSN: env.SENTRY_BEMO_WORKER_DSN,
},
sentry: {
authToken: env.SENTRY_AUTH_TOKEN,
project: 'bemo-worker',
},
})
}
main().catch(async (err) => {
// don't notify discord on preview builds
if (env.TLDRAW_ENV !== 'preview') {
await discord.message(`${Discord.AT_TEAM_MENTION} Deploy failed: ${err.stack}`, {
always: true,
})
}
console.error(err)
process.exit(1)
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
import { readdir } from 'fs/promises'
import { join } from 'path'
import { exec } from './lib/exec'
import { readJsonIfExists, REPO_ROOT } from './lib/file'
import { makeEnv } from './lib/makeEnv'
import { nicelog } from './lib/nicelog'
import { EXPORT_CONFIG_KEY, PackageJson } from './lib/types'
const TEMPLATE_DIR = join(REPO_ROOT, 'templates')
/**
* Like `makeEnv`, but also rejects empty values. Github expands an unconfigured secret or variable
* to the empty string rather than leaving it unset, so `makeEnv` alone would let a missing license
* key through and we'd deploy unlicensed demos on a green CI run — the exact thing this script
* exists to prevent.
*/
function requireEnv<const Keys extends readonly string[]>(keys: Keys) {
const env = makeEnv(keys)
const emptyVars = keys.filter((key) => env[key as keyof typeof env] === '')
if (emptyVars.length > 0) {
throw new Error(`Empty environment variables: ${emptyVars.join(', ')}`)
}
return env
}
// The license key for the demos deployed from here. Don't set one in the cloudflare dashboard as
// well: a key configured there is invisible to this repo, so nothing catches it silently expiring.
//
// The templates themselves never reference it. tldraw's LicenseProvider picks it up from the build
// environment (see getLicenseKeyFromEnv in packages/editor/src/lib/license/LicenseProvider.tsx), so
// the exported public templates stay free of our key.
const env = requireEnv(['TEMPLATES_TLDRAW_LICENSE_KEY'])
// every template deployed from here builds with vite. A next.js template would need
// NEXT_PUBLIC_TLDRAW_LICENSE_KEY instead.
const licenseEnv = { VITE_TLDRAW_LICENSE_KEY: env.TEMPLATES_TLDRAW_LICENSE_KEY }
async function main() {
const templateName = process.argv[2]
if (!templateName) {
nicelog('Usage: tsx deploy-template.ts <template-name>')
process.exit(1)
}
const possibleTemplates = (await readdir(TEMPLATE_DIR, { withFileTypes: true }))
.filter((d) => d.isDirectory())
.map((d) => d.name)
if (!possibleTemplates.includes(templateName)) {
nicelog(`Template "${templateName}" not found.`)
nicelog(`Possible templates: ${possibleTemplates.join(', ')}`)
process.exit(1)
}
const templateDir = join(TEMPLATE_DIR, templateName)
const packageJsonRaw = await readJsonIfExists(join(templateDir, 'package.json'))
if (!packageJsonRaw) {
nicelog('No package.json found')
process.exit(1)
}
const packageJson = PackageJson.validate(packageJsonRaw)
const deployConfig = packageJson[EXPORT_CONFIG_KEY]?.deploy
if (!deployConfig) {
nicelog(`Template "${templateName}" has no deploy config. Skipping.`)
process.exit(0)
}
await deployToCloudflare(templateName, templateDir)
nicelog(`Deployed ${templateName} to https://${deployConfig.host}`)
}
async function deployToCloudflare(templateName: string, templateDir: string) {
const cloudflareEnv = requireEnv(['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN'])
nicelog(`Building ${templateName}...`)
await exec('yarn', ['run', 'build'], {
pwd: templateDir,
env: { ...licenseEnv, NODE_ENV: 'production' },
})
// The route (including the custom domain) comes from the template's own wrangler.toml, which
// ships to users too — so the deploy target is described in one place rather than here.
nicelog(`Deploying ${templateName} to cloudflare...`)
await exec('yarn', ['wrangler', 'deploy'], {
pwd: templateDir,
env: {
...cloudflareEnv,
NODE_ENV: 'production',
// wrangler needs CI=1 set to prevent it from trying to do interactive prompts
CI: '1',
},
})
}
main().catch((err) => {
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,45 @@
#!/bin/bash
template_name="$1"
shift || true
vite_args=("$@")
workspace_root="$(git rev-parse --show-toplevel)"
if [ -z "$template_name" ]; then
echo "Usage: yarn dev-template <template_name>"
echo "Available templates: $(ls templates | awk 'ORS=", "' | sed 's/, $//')"
exit 1
fi
if [ ! -d "templates/$template_name" ]; then
echo "Template $template_name does not exist"
echo "Available templates: $(ls templates | awk 'ORS=", "' | sed 's/, $//')"
exit 1
fi
if [ ${#vite_args[@]} -eq 0 ]; then
LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter="templates/$template_name" --filter='packages/tldraw' --filter='apps/bemo-worker'
exit $?
fi
# Running vite directly skips lazy's `dev` prerequisites. Only refresh-assets feeds templates, so
# run it here first (usually a cache hit) and bail on failure like the normal `lazy run dev` path.
LAZYREPO_PRETTY_OUTPUT=0 lazy run refresh-assets || exit 1
# lazy doesn't forward extra args, so run vite directly in the template below. Run the shared deps
# in their own process group (set -m) so cleanup can kill lazy and its watchers, which lazy itself
# won't (it installs no signal handlers, so killing just its pid would orphan them).
set -m
LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='packages/tldraw' --filter='apps/bemo-worker' &
lazy_pid=$!
disown
cleanup() {
# negative pid signals the whole process group
kill -- "-$lazy_pid" 2>/dev/null
}
trap cleanup EXIT INT TERM
cd "$workspace_root/templates/$template_name" || exit 1
# Args are appended to the template's `dev` script, so they only reach vite for plain-vite templates.
# For concurrently-wrapped or next.js templates they hit the wrapper/next instead.
yarn dev "${vite_args[@]}"

View File

@@ -0,0 +1,72 @@
import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { Client } from 'pg'
import { makeEnv } from '../lib/makeEnv'
const env = makeEnv([
'CLOUDFLARE_ACCOUNT_ID',
'R2_ACCESS_KEY_ID',
'R2_ACCESS_KEY_SECRET',
'SUPABASE_STAGING_DB_URL',
'STAGING_OWNER_ID',
])
const R2_URL = `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`
const R2_STAGING_BUCKET = 'rooms-preview'
const R2 = new S3Client({
region: 'auto',
endpoint: R2_URL,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_ACCESS_KEY_SECRET,
},
})
async function cleanupTestFiles() {
const stagingClient = new Client({
connectionString: env.SUPABASE_STAGING_DB_URL,
})
await stagingClient.connect()
try {
const selectQuery = `SELECT id FROM file WHERE file."owningGroupId" = $1 AND id LIKE 'test_%'`
const selectResult = await stagingClient.query(selectQuery, [env.STAGING_OWNER_ID])
const fileIds = selectResult.rows.map((row: any) => row.id)
for (const fileId of fileIds) {
try {
const deleteParams = {
Bucket: R2_STAGING_BUCKET,
Key: `app_rooms/${fileId}`,
}
const deleteResponse = await R2.send(new DeleteObjectCommand(deleteParams))
if (
!deleteResponse.$metadata.httpStatusCode ||
(deleteResponse.$metadata.httpStatusCode !== 200 &&
deleteResponse.$metadata.httpStatusCode !== 204)
) {
console.error('Failed to delete file from R2 staging bucket')
continue
}
const deleteFileQuery = `DELETE FROM file WHERE id = $1`
const deleteResult = await stagingClient.query(deleteFileQuery, [fileId])
if (deleteResult.rowCount === 0) {
console.error('Failed to delete file from Supabase database - no rows affected')
continue
}
} catch (error) {
console.error('Failed to delete file', error)
}
}
console.log(`Successfully cleaned up ${fileIds.length} test files`)
} finally {
await stagingClient.end()
}
}
cleanupTestFiles().catch(console.error)

View File

@@ -0,0 +1,182 @@
import { writeFileSync } from 'fs'
import { join } from 'path'
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import type { TlaFile } from '@tldraw/dotcom-shared'
import { nanoid } from 'nanoid'
import { Client } from 'pg'
import { makeEnv } from '../lib/makeEnv'
const env = makeEnv([
'CLOUDFLARE_ACCOUNT_ID',
'R2_ACCESS_KEY_ID',
'R2_ACCESS_KEY_SECRET',
'SUPABASE_PRODUCTION_DB_URL',
'SUPABASE_STAGING_DB_URL',
'STAGING_OWNER_ID',
])
const R2_URL = `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`
const R2_PRODUCTION_BUCKET = 'rooms'
const R2_STAGING_BUCKET = 'rooms-preview'
const R2 = new S3Client({
region: 'auto',
endpoint: R2_URL,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_ACCESS_KEY_SECRET,
},
})
async function getRandomFileIds(n: number): Promise<string[]> {
const client = new Client({
connectionString: env.SUPABASE_PRODUCTION_DB_URL,
})
await client.connect()
const threeDays = 3 * 24 * 60 * 60 * 1000
const query = `
SELECT * FROM file
TABLESAMPLE BERNOULLI(30)
WHERE "shared" = true
AND ("updatedAt" - "createdAt") >= $1
LIMIT $2;
`
const { rows } = await client.query(query, [threeDays, n])
await client.end()
if (!rows || rows.length === 0) {
console.log('No files found matching the criteria')
return []
}
return rows.map((r) => r.id)
}
async function copyFilesToStaging(fileIds: string[]) {
const copiedIds: string[] = []
// Create one staging client for all database operations
const stagingClient = new Client({
connectionString: env.SUPABASE_STAGING_DB_URL,
})
await stagingClient.connect()
try {
for (const fileId of fileIds) {
try {
const getParams = {
Bucket: R2_PRODUCTION_BUCKET,
Key: `app_rooms/${fileId}`,
}
const getResponse = await R2.send(new GetObjectCommand(getParams))
if (!getResponse.Body) {
console.error('Failed to download file - no body')
continue
}
const fileContent = await getResponse.Body.transformToByteArray()
const newId = `test_${nanoid()}`
const putParams = {
Bucket: R2_STAGING_BUCKET,
Key: `app_rooms/${newId}`,
Body: Buffer.from(fileContent),
ContentType: 'application/octet-stream',
}
const putResponse = await R2.send(new PutObjectCommand(putParams))
if (!putResponse.$metadata.httpStatusCode || putResponse.$metadata.httpStatusCode !== 200) {
console.error('Failed to upload file to staging bucket')
continue
}
const now = Date.now()
const testFile: TlaFile = {
id: newId,
name: 'Test File',
ownerName: 'Test User',
thumbnail: '',
shared: false,
sharedLinkType: 'link',
published: false,
lastPublished: 0,
publishedSlug: nanoid(),
createdAt: now,
updatedAt: now,
isEmpty: false,
isDeleted: false,
createSource: null,
// the staging user's home workspace: home group id is the user's own id. Must be set —
// file_owner_xor_check rejects a row with neither owner, and cleanup-test-files finds
// these rows by owningGroupId.
owningGroupId: env.STAGING_OWNER_ID,
}
const insertQuery = `
INSERT INTO file (id, name, "ownerName", thumbnail, shared, "sharedLinkType", published, "lastPublished", "publishedSlug", "createdAt", "updatedAt", "isEmpty", "isDeleted", "owningGroupId")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
`
await stagingClient.query(insertQuery, [
testFile.id,
testFile.name,
testFile.ownerName,
testFile.thumbnail,
testFile.shared,
testFile.sharedLinkType,
testFile.published,
testFile.lastPublished,
testFile.publishedSlug,
testFile.createdAt,
testFile.updatedAt,
testFile.isEmpty,
testFile.isDeleted,
testFile.owningGroupId,
])
await stagingClient.query(
`INSERT INTO group_file ("fileId", "groupId", "createdAt", "updatedAt", "index")
VALUES ($1, $2, $3, $4, NULL)`,
[testFile.id, testFile.owningGroupId, now, now]
)
copiedIds.push(newId)
} catch (error) {
console.error('Error copying file:', error)
}
}
} finally {
await stagingClient.end()
}
console.log(`Successfully copied ${copiedIds.length} files to staging`)
if (copiedIds.length > 0) {
const testFilesPath = join(
__dirname,
'../../../apps/dotcom/client/e2e/fixtures/test-files.json'
)
writeFileSync(testFilesPath, JSON.stringify(copiedIds, null, 2))
console.log('Updated test files')
}
}
async function main() {
const n = parseInt(process.env.STAGING_TEST_FILES_COUNT || '10')
console.log(`Getting ${n} random file IDs...`)
const fileIds = await getRandomFileIds(n)
if (fileIds.length === 0) {
console.log('No files found to copy')
return
}
console.log('\nCopying files to staging...')
await copyFilesToStaging(fileIds)
}
main().catch(console.error)

View File

@@ -0,0 +1,170 @@
import { copyFile, mkdir, mkdtemp, readdir, readFile, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { dirname, join } from 'path'
import { exec } from './lib/exec'
import { readJsonIfExists, REPO_ROOT, writeCodeFile, writeJsonFile } from './lib/file'
import { EXPORT_CONFIG_KEY, PackageJson, TsConfigJson } from './lib/types'
const TEMPLATE_DIR = join(REPO_ROOT, 'templates')
async function main() {
const templateName = process.argv[2]
if (!templateName) {
console.log('Usage: tsx prepare-template-for-export.ts <template-name>')
process.exit(1)
}
const possibleTemplates = (await readdir(TEMPLATE_DIR, { withFileTypes: true }))
.filter((d) => d.isDirectory())
.map((d) => d.name)
if (!possibleTemplates.includes(templateName)) {
console.log(`Template "${templateName}" not found.`)
console.log(`Possible templates: ${possibleTemplates.join(', ')}`)
process.exit(1)
}
const sourceDir = join(REPO_ROOT, 'templates', templateName)
const githubAuth = getGithubAuth()
// make a temporary working dir:
const workingDir = await mkdtemp(join(tmpdir(), `template-export-${templateName}`))
console.log(`Working in ${workingDir}`)
// read the package.json:
const packageJsonRaw = await readJsonIfExists(join(sourceDir, 'package.json'))
if (!packageJsonRaw) {
console.log('No package.json found')
process.exit(1)
}
const packageJson = PackageJson.validate(packageJsonRaw)
const exportConfig = packageJson[EXPORT_CONFIG_KEY]
if (!exportConfig) {
console.log('No export config found in package.json.')
console.log(`Please add a "${EXPORT_CONFIG_KEY}" key to your package.json.`)
console.log('Skipping for now.')
process.exit(0)
}
// clone the template repo:
const repoUrl = `https://${githubAuth}github.com/${exportConfig.repo}.git`
console.log(`Cloning ${repoUrl}...`)
await exec('git', ['clone', repoUrl, workingDir, '--depth', '1'])
console.log('Clearing old files...')
const oldFiles = (await readdir(workingDir)).filter((f) => f !== '.git')
for (const file of oldFiles) {
await rm(join(workingDir, file), { recursive: true })
}
console.log('Copying files to export dir...')
const templateRepoPrefix = `templates/${templateName}/`
const templateFiles = (await exec('git', ['ls-files', templateRepoPrefix]))
.split('\n')
.map((s) => s.replace(templateRepoPrefix, ''))
.filter((s) => s.trim())
// Monorepo-only Vercel deploy config; its commands don't work outside this workspace.
.filter((s) => s !== 'vercel.json')
await mkdir(workingDir, { recursive: true })
for (const file of templateFiles) {
const targetFile = join(workingDir, file)
await mkdir(dirname(targetFile), { recursive: true })
if (isTypeScriptSourceFile(file)) {
const source = await readFile(join(REPO_ROOT, templateRepoPrefix, file), 'utf-8')
const cleanedSource = source.replace(
/\/\* EXCLUDE_FROM_TEMPLATE_EXPORT_START \*\/(.*?)\/\* EXCLUDE_FROM_TEMPLATE_EXPORT_END \*\//gs,
''
)
await writeCodeFile(null, 'typescript', targetFile, cleanedSource)
} else {
await copyFile(join(REPO_ROOT, templateRepoPrefix, file), targetFile)
}
}
console.log('Cleaning package.json...')
delete packageJson[EXPORT_CONFIG_KEY]
if (exportConfig?.scripts) {
if (!packageJson.scripts) {
packageJson.scripts = {}
}
for (const [name, script] of Object.entries(exportConfig.scripts)) {
if (script === null) {
delete packageJson.scripts[name]
} else {
packageJson.scripts[name] = script
}
}
}
await setWorkspaceDependenciesToLatest(packageJson.dependencies)
await setWorkspaceDependenciesToLatest(packageJson.devDependencies)
await setWorkspaceDependenciesToLatest(packageJson.peerDependencies)
await writeJsonFile(join(workingDir, 'package.json'), packageJson)
console.log('Cleaning tsconfig.json...')
const tsconfigJsonRaw = await readJsonIfExists(join(workingDir, 'tsconfig.json'))
if (!tsconfigJsonRaw) {
console.log('No tsconfig.json found')
process.exit(1)
}
const tsconfigJson = TsConfigJson.validate(tsconfigJsonRaw)
if (tsconfigJson.references) {
tsconfigJson.references = tsconfigJson.references.filter((ref) => !ref.path.startsWith('.'))
if (tsconfigJson.references.length === 0) {
delete tsconfigJson.references
}
}
await writeJsonFile(join(workingDir, 'tsconfig.json'), tsconfigJson)
console.log('Committing...')
const latestTldrawVersion = await getLatestPackageVersion('tldraw')
await exec('git', ['add', '.'], { pwd: workingDir })
await exec(
'git',
['commit', '--allow-empty', '-m', `update from tldraw ${latestTldrawVersion}`],
{ pwd: workingDir }
)
console.log('Pushing...')
await exec('git', ['push', repoUrl, 'main'], { pwd: workingDir })
console.log('Cleaning up temp dir...')
await rm(workingDir, { recursive: true })
console.log('Done!')
}
function getGithubAuth() {
const githubToken = process.env.GITHUB_TOKEN
if (!githubToken) return ''
return `huppy-bot:${githubToken}@`
}
async function setWorkspaceDependenciesToLatest(dependencies?: Record<string, string>) {
if (!dependencies) return
for (const [name, version] of Object.entries(dependencies)) {
if (version.startsWith('workspace:')) {
const latestVersion = await getLatestPackageVersion(name)
dependencies[name] = `^${latestVersion}`
}
}
}
async function getLatestPackageVersion(packageName: string) {
const latestVersion = await exec('npm', ['view', `${packageName}@latest`, 'version'])
return latestVersion.trim()
}
function isTypeScriptSourceFile(path: string) {
return path.endsWith('.ts') || path.endsWith('.tsx')
}
main()

View File

@@ -0,0 +1,258 @@
import { join } from 'path'
import { exec } from './lib/exec'
import { REPO_ROOT, writeStringFile } from './lib/file'
import { nicelog } from './lib/nicelog'
function convertPRLinks(text: string): string {
// Convert (#1234) to ([#1234](https://github.com/tldraw/tldraw/issues/1234))
return text.replace(/\(#(\d+)\)/g, '([#$1](https://github.com/tldraw/tldraw/issues/$1))')
}
function extractRelevantSections(message: string): string {
// Extract Release Notes and API Changes sections (case-insensitive, flexible markdown heading levels)
const lines = message.split('\n')
const relevantSections: string[] = []
let inRelevantSection = false
let currentSection: string[] = []
for (const line of lines) {
// Check if this is a heading line (one or more #s followed by text)
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/i)
if (headingMatch) {
const headingText = headingMatch[2].trim().toLowerCase()
// If we were in a relevant section, save it
if (inRelevantSection && currentSection.length > 0) {
relevantSections.push(currentSection.join('\n'))
currentSection = []
}
// Check if this is a Release Notes or API Changes heading
if (headingText.includes('release note') || headingText.includes('api change')) {
inRelevantSection = true
// Include the heading itself in the section
currentSection.push(line)
} else {
inRelevantSection = false
}
} else if (inRelevantSection) {
// We're in a relevant section, collect the line
currentSection.push(line)
}
}
// Don't forget the last section
if (inRelevantSection && currentSection.length > 0) {
relevantSections.push(currentSection.join('\n'))
}
// If we found relevant sections, return them joined together
if (relevantSections.length > 0) {
// Remove the section headings themselves (first line of each section)
const sectionsWithoutHeadings = relevantSections
.map((section) => {
const sectionLines = section.split('\n')
// Remove the first line (the heading) and keep the rest
return sectionLines.slice(1).join('\n').trim()
})
.filter((section) => section.length > 0)
return sectionsWithoutHeadings.join('\n\n').trim()
}
// Otherwise, return empty string (no content to include)
return ''
}
async function getLatestMinorRelease() {
// otherwise get the latest minor release
const tags = (await exec('git', ['tag', '--list', `v*.*.0`, '--sort=-version:refname']))
.toString()
.trim()
return tags.split('\n')[0]
}
export async function extractChangelog(startRef: string, endRef: string): Promise<string> {
nicelog(`Extracting changelog from ${startRef} to ${endRef}`)
const commitHashes = (await exec('git', ['cherry', startRef, endRef]))
.trim()
.split('\n')
.filter((line) => !line.startsWith('-'))
.map((line) => line.split(' ')[1])
nicelog(`Found ${commitHashes.length} commits`)
// Extract full commit messages for each commit
const entries: Array<{
firstLine: string
restOfMessage: string
changeType: string
changedFolders: string[]
}> = []
for (const hash of commitHashes) {
const fullMessage = await exec('git', ['log', '-1', '--pretty=format:%B', hash], {
processStdoutLine: () => {}, // Suppress output
processStderrLine: () => {},
})
const lines = fullMessage.trim().split('\n')
const firstLine = lines[0] || hash
// Skip commits with [skip ci] in the title
if (firstLine.includes('[skip ci]')) {
continue
}
// Get changed files for this commit
const changedFiles = await exec('git', ['show', '--name-only', '--pretty=format:', hash], {
processStdoutLine: () => {}, // Suppress output
processStderrLine: () => {},
})
// Extract unique folders (max 2 levels deep) from changed files
const folders = new Set<string>()
changedFiles
.split('\n')
.filter((file) => file.trim())
.forEach((file) => {
const parts = file.split('/')
if (parts.length >= 2) {
folders.add(`${parts[0]}/${parts[1]}`)
} else if (parts.length === 1) {
folders.add(parts[0])
}
})
let restOfMessage = lines.slice(1).join('\n').trim()
// Extract change type from checkboxes
let changeType = 'other'
const changeTypes = ['bugfix', 'improvement', 'feature', 'api', 'other']
for (const type of changeTypes) {
if (restOfMessage.includes(`- [x] \`${type}\``)) {
changeType = type
break
}
}
// Remove the Change type section and all checkboxes
restOfMessage = restOfMessage
// remove cursor summary
.replace(/<!-- CURSOR_SUMMARY -->(.|\s)*/gm, '')
// remove Change type section
.replace(/### Change type[\s\n]*/g, '')
// remove checkboxes
.replace(/- \[[ x]\] `(bugfix|improvement|feature|api|other)`[\s\n]*/g, '')
// remove co-authored-by lines
.replace(/^co-authored-by: .*$/gim, '')
// Clean up excessive newlines
.replace(/\n{3,}/g, '\n\n')
// remove trailing horizontal rule
.trim()
.replace(/-{3,}$/, '')
.trim()
// Extract only Release Notes and API Changes sections
const extractedContent = extractRelevantSections(restOfMessage)
// Convert PR links like (#1234) to full URLs
const contentWithLinks = convertPRLinks(extractedContent)
// Only include entries that touch packages/ folder (but not internal packages)
const excludedPackages = ['packages/dotcom-shared', 'packages/worker-shared']
const touchesPackages = Array.from(folders).some(
(folder) => folder.startsWith('packages/') && !excludedPackages.includes(folder)
)
if (touchesPackages) {
entries.push({
firstLine: convertPRLinks(firstLine),
restOfMessage: contentWithLinks,
changeType,
changedFolders: Array.from(folders).sort(),
})
}
}
// Group entries by change type
const groupedEntries: Record<string, typeof entries> = {
bugfix: [],
improvement: [],
feature: [],
api: [],
other: [],
}
for (const entry of entries) {
groupedEntries[entry.changeType].push(entry)
}
// Generate output.md content
let output = `Generated from commits between \`${startRef}\` and \`${endRef}\`\n\n`
// Output each change type section
const changeTypeLabels = {
bugfix: 'Bug Fixes',
improvement: 'Improvements',
feature: 'Features',
api: 'API Changes',
other: 'Other Changes',
}
for (const [changeType, label] of Object.entries(changeTypeLabels)) {
const entriesForType = groupedEntries[changeType]
if (entriesForType.length === 0) continue
output += `## ${label}\n\n`
for (const entry of entriesForType) {
output += `### ${entry.firstLine}\n\n`
if (entry.restOfMessage) {
output += `${entry.restOfMessage}\n\n`
}
}
}
nicelog(`Extracted ${entries.length} changelog entries`)
return output
}
async function main() {
const args = process.argv.slice(2)
await exec('git', ['fetch', 'origin'])
await exec('git', ['fetch', 'origin', '--tags'])
if (args.includes('--help') || args.includes('-h')) {
console.error('Usage: yarn extract-draft-changelog [start-ref] [end-ref]')
console.error('Example: yarn extract-draft-changelog v2.0.0 HEAD')
console.error('Example: yarn extract-draft-changelog v2.0.0 # uses current HEAD')
process.exit(1)
}
const startRef = args[0] || (await getLatestMinorRelease())
const endRef = args[1] || 'origin/production'
try {
const output = await extractChangelog(startRef, endRef)
// Write to changelog.md in the repo root
const outputPath = join(REPO_ROOT, 'changelog.md')
await writeStringFile(outputPath, output)
nicelog(`Draft changelog written to changelog.md`)
} catch (error) {
console.error('Error extracting changelog:', error)
process.exit(1)
}
}
if (require.main === module) {
main().catch((error) => {
console.error('Unhandled error:', error)
process.exit(1)
})
}

View File

@@ -0,0 +1,126 @@
/**
* Fetch logs from Fly.io's VictoriaLogs API.
*
* Usage:
* npx tsx internal/scripts/fetch-fly-logs.ts [options]
*
* Examples:
* npx tsx internal/scripts/fetch-fly-logs.ts --app production-zero-rm --last 2h
* npx tsx internal/scripts/fetch-fly-logs.ts --app production-zero-rm --last 30m --filter "Purged"
* npx tsx internal/scripts/fetch-fly-logs.ts --app production-zero-rm --start 2026-04-15T15:00:00Z --end 2026-04-15T18:00:00Z
* npx tsx internal/scripts/fetch-fly-logs.ts --app production-zero-rm --last 1h --filter "NOT DEBUG" -o logs.jsonl
* npx tsx internal/scripts/fetch-fly-logs.ts --app production-zero-rm --last 6h --filter "Purged" --pretty
* FLY_TOKEN=fo1_xxx npx tsx internal/scripts/fetch-fly-logs.ts --app production-zero-rm --last 1h
* npx tsx internal/scripts/fetch-fly-logs.ts --token fo1_xxx --app production-zero-rm --last 1h
*
* See fly-common.ts for app names and token configuration.
*/
import { FLY_ORG_SLUG, getFlyToken, parseDuration } from './fly-common'
const BASE_URL = `https://api.fly.io/victorialogs/${FLY_ORG_SLUG}/select/logsql/query`
function parseArgs() {
const args = process.argv.slice(2)
const opts: Record<string, string> = {}
for (let i = 0; i < args.length; i++) {
if (args[i] === '--token' || args[i] === '-t') opts.token = args[++i]
else if (args[i] === '--app' || args[i] === '-a') opts.app = args[++i]
else if (args[i] === '--start') opts.start = args[++i]
else if (args[i] === '--end') opts.end = args[++i]
else if (args[i] === '--last' || args[i] === '-l') opts.last = args[++i]
else if (args[i] === '--filter' || args[i] === '-f') opts.filter = args[++i]
else if (args[i] === '--limit') opts.limit = args[++i]
else if (args[i] === '--output' || args[i] === '-o') opts.output = args[++i]
else if (args[i] === '--pretty' || args[i] === '-p') opts.pretty = 'true'
else if (args[i] === '--help' || args[i] === '-h') {
console.log(
[
'Usage: npx tsx internal/scripts/fetch-fly-logs.ts [options]',
'',
'Options:',
' --token, -t Fly API token (falls back to FLY_TOKEN env var)',
' --app, -a Fly app name (required)',
' --last, -l Duration to look back, e.g. 30m, 2h, 1d (default: 1h)',
' --start Start time (ISO 8601), overrides --last',
' --end End time (ISO 8601), defaults to now',
' --filter, -f Additional LogsQL filter, e.g. "Purged", "NOT DEBUG"',
' --limit Max lines (default: unlimited)',
' --output, -o Write to file instead of stdout',
' --pretty, -p Pretty-print: timestamp + message only',
' --help, -h Show this help',
].join('\n')
)
process.exit(0)
} else {
console.error(`Unknown option: ${args[i]}`)
process.exit(1)
}
}
if (!opts.app) {
console.error('--app is required')
process.exit(1)
}
return opts
}
async function main() {
const opts = parseArgs()
const now = new Date()
const end = opts.end ?? now.toISOString()
let start: string
if (opts.start) {
start = opts.start
} else {
const ms = parseDuration(opts.last ?? '1h', 'ms')
start = new Date(now.getTime() - ms).toISOString()
}
let query = `fly.app.name: ${opts.app}`
if (opts.filter) query += ` AND ${opts.filter}`
const params = new URLSearchParams({ query, start, end })
if (opts.limit) params.set('limit', opts.limit)
const token = getFlyToken(opts.token)
const url = `${BASE_URL}?${params}`
const res = await fetch(url, { headers: { Authorization: `FlyV1 ${token}` } })
if (!res.ok) {
console.error(`HTTP ${res.status}: ${await res.text()}`)
process.exit(1)
}
const body = await res.text()
if (!body.trim()) {
console.error('No logs found.')
process.exit(0)
}
let output: string
if (opts.pretty) {
output = body
.trim()
.split('\n')
.map((line) => {
const entry = JSON.parse(line)
return `${entry._time} ${entry._msg}`
})
.sort()
.join('\n')
} else {
output = body
}
if (opts.output) {
const fs = await import('fs')
fs.writeFileSync(opts.output, output + '\n')
const lines = output.trim().split('\n').length
console.error(`Wrote ${lines} lines to ${opts.output}`)
} else {
process.stdout.write(output + '\n')
}
}
main()

View File

@@ -0,0 +1,201 @@
/**
* Fetch CPU and memory metrics from Fly.io's Prometheus API.
*
* Usage:
* npx tsx internal/scripts/fetch-fly-metrics.ts [options]
*
* Examples:
* npx tsx internal/scripts/fetch-fly-metrics.ts --app production-zero-rm --last 1h
* npx tsx internal/scripts/fetch-fly-metrics.ts --app production-zero-rm --last 6h --step 5m
* npx tsx internal/scripts/fetch-fly-metrics.ts --token fo1_xxx --app production-zero-rm --last 2h
* FLY_TOKEN=fo1_xxx npx tsx internal/scripts/fetch-fly-metrics.ts --app production-zero-rm --last 1h
*
* See fly-common.ts for app names and token configuration.
*/
import { FLY_ORG_SLUG, getFlyToken, parseDuration } from './fly-common'
const PROM_URL = `https://api.fly.io/prometheus/${FLY_ORG_SLUG}/api/v1/query_range`
function parseArgs() {
const args = process.argv.slice(2)
const opts: Record<string, string> = {}
for (let i = 0; i < args.length; i++) {
if (args[i] === '--token' || args[i] === '-t') opts.token = args[++i]
else if (args[i] === '--app' || args[i] === '-a') opts.app = args[++i]
else if (args[i] === '--last' || args[i] === '-l') opts.last = args[++i]
else if (args[i] === '--step' || args[i] === '-s') opts.step = args[++i]
else if (args[i] === '--output' || args[i] === '-o') opts.output = args[++i]
else if (args[i] === '--help' || args[i] === '-h') {
console.log(
[
'Usage: npx tsx internal/scripts/fetch-fly-metrics.ts [options]',
'',
'Options:',
' --token, -t Fly API token (falls back to FLY_TOKEN env var)',
' --app, -a Fly app name (required)',
' --last, -l Duration to look back, e.g. 30m, 2h, 1d (default: 1h)',
' --step, -s Query resolution step, e.g. 1m, 5m (default: 1m)',
' --output, -o Write to file instead of stdout',
' --help, -h Show this help',
].join('\n')
)
process.exit(0)
} else {
console.error(`Unknown option: ${args[i]}`)
process.exit(1)
}
}
if (!opts.app) {
console.error('--app is required')
process.exit(1)
}
return opts
}
interface PromResult {
metric: Record<string, string>
values: [number, string][]
}
async function queryRange(
token: string,
query: string,
start: number,
end: number,
step: string
): Promise<PromResult[]> {
const params = new URLSearchParams({
query,
start: start.toString(),
end: end.toString(),
step,
})
const res = await fetch(`${PROM_URL}?${params}`, {
headers: { Authorization: `FlyV1 ${token}` },
})
if (!res.ok) {
console.error(`HTTP ${res.status}: ${await res.text()}`)
process.exit(1)
}
const json = (await res.json()) as { data: { result: PromResult[] } }
return json.data.result
}
function formatBytes(bytes: number): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)}GB`
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)}MB`
return `${(bytes / 1e3).toFixed(1)}KB`
}
function formatTime(epoch: number): string {
return new Date(epoch * 1000)
.toISOString()
.replace('T', ' ')
.replace(/\.\d+Z/, 'Z')
}
function seriesLabel(metric: Record<string, string>): string {
const instance = metric.instance ?? 'unknown'
const region = metric.region ?? ''
return region ? `${instance} (${region})` : instance
}
async function main() {
const opts = parseArgs()
const token = getFlyToken(opts.token)
const step = opts.step ?? '1m'
const now = Math.floor(Date.now() / 1000)
const rangeSec = parseDuration(opts.last ?? '1h')
const start = now - rangeSec
const app = opts.app
// Query A: CPU usage % (excludes idle + steal, normalized by core count)
const cpuQuery = `sum(increase(fly_instance_cpu{app="${app}", mode!="idle", mode!="steal"}[60s]))by(instance, region)/60 / sum(count(fly_instance_cpu{app="${app}", mode="idle"})without(cpu))by(instance, region)`
// Query B: CPU baseline (shared CPU fraction)
const baselineQuery = `mode(fly_instance_cpu_baseline{app="${app}"}) / mode(count(fly_instance_cpu{app="${app}", mode="idle"})without(cpu_id, mode)) * 100`
// Query C: CPU throttle indicator
const throttleQuery = `avg(rate(fly_instance_cpu_throttle{app="${app}"}[60s])) / count(fly_instance_cpu{app="${app}", mode="idle"})without(cpu_id, mode)`
// Memory used
const memQuery = `fly_instance_memory_mem_total{app="${app}"} - fly_instance_memory_mem_available{app="${app}"}`
const memTotalQuery = `fly_instance_memory_mem_total{app="${app}"}`
const [cpuResults, baselineResults, throttleResults, memResults, memTotalResults] =
await Promise.all([
queryRange(token, cpuQuery, start, now, step),
queryRange(token, baselineQuery, start, now, step),
queryRange(token, throttleQuery, start, now, step),
queryRange(token, memQuery, start, now, step),
queryRange(token, memTotalQuery, start, now, step),
])
if (!cpuResults.length && !memResults.length) {
console.error('No metrics found.')
process.exit(0)
}
const lines: string[] = []
lines.push(`Metrics for ${app} (last ${opts.last ?? '1h'}, step ${step})`)
// CPU time series
for (const series of cpuResults) {
const label = seriesLabel(series.metric)
const values = series.values.map((v) => parseFloat(v[1]))
const avg = values.reduce((a, b) => a + b, 0) / values.length
const max = Math.max(...values)
lines.push('')
lines.push(`--- CPU [${label}] avg=${avg.toFixed(1)}% max=${max.toFixed(1)}% ---`)
for (const [ts, val] of series.values) {
lines.push(` ${formatTime(ts)} ${parseFloat(val).toFixed(1)}%`)
}
}
// CPU baseline
for (const series of baselineResults) {
const label = seriesLabel(series.metric)
const values = series.values.map((v) => parseFloat(v[1]))
const avg = values.reduce((a, b) => a + b, 0) / values.length
lines.push('')
lines.push(`--- CPU baseline [${label}] avg=${avg.toFixed(1)}% ---`)
}
// CPU throttle
for (const series of throttleResults) {
const values = series.values.map((v) => parseFloat(v[1]))
const throttled = values.filter((v) => v > 1).length
if (throttled > 0) {
lines.push('')
lines.push(`--- CPU throttled: ${throttled}/${values.length} samples ---`)
}
}
// Memory time series
for (const series of memResults) {
const label = seriesLabel(series.metric)
const values = series.values.map((v) => parseFloat(v[1]))
const avg = values.reduce((a, b) => a + b, 0) / values.length
const max = Math.max(...values)
const totalSeries = memTotalResults.find((t) => t.metric.instance === series.metric.instance)
const total = totalSeries?.values[0] ? parseFloat(totalSeries.values[0][1]) : 0
const totalStr = total ? ` / ${formatBytes(total)}` : ''
lines.push('')
lines.push(`--- MEM [${label}] avg=${formatBytes(avg)} max=${formatBytes(max)}${totalStr} ---`)
for (const [ts, val] of series.values) {
const pct = total ? ` (${((parseFloat(val) / total) * 100).toFixed(1)}%)` : ''
lines.push(` ${formatTime(ts)} ${formatBytes(parseFloat(val))}${pct}`)
}
}
const output = lines.join('\n')
if (opts.output) {
const fs = await import('fs')
fs.writeFileSync(opts.output, output + '\n')
console.error(`Wrote to ${opts.output}`)
} else {
process.stdout.write(output + '\n')
}
}
main()

View File

@@ -0,0 +1,321 @@
/**
* Fetch board screenshot telemetry (the mcp_shared_board_screenshot dataset events) from the
* Cloudflare Analytics Engine SQL API and report failure rate, timeout rate, cache hit rate, and
* Browser Run render time per request source (mcp = MCP tool, og = GET og-image route, queue = async
* render consumer).
*
* Render time is wall-clock time around each Browser Run capture (double3), not Cloudflare's billed
* browser milliseconds: the BROWSER binding does not surface a per-render billed-ms figure (double4 is
* always -1), so wall-clock render time is the available proxy for Browser Run spend.
*
* Usage:
* npx tsx internal/scripts/fetch-screenshot-metrics.ts [options]
*
* Examples:
* npx tsx internal/scripts/fetch-screenshot-metrics.ts --last 24h
* npx tsx internal/scripts/fetch-screenshot-metrics.ts --worker main-tldraw-multiplayer --last 7d
* npx tsx internal/scripts/fetch-screenshot-metrics.ts --check --max-failure-rate 0.2 --max-render-minutes 300
*
* With --check the script exits non-zero when a threshold is breached, so it can back a scheduled
* alert (cron CI job, uptime monitor, etc.) without any extra infrastructure.
*
* Auth: set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_ANALYTICS_API_TOKEN (an API token with the
* "Account Analytics: Read" permission; CLOUDFLARE_API_TOKEN works as a fallback).
*/
const EVENT_NAME = 'mcp_shared_board_screenshot'
interface Options {
lastHours: number
worker?: string
dataset: string
json: boolean
check: boolean
maxFailureRate?: number
maxTimeoutRate?: number
minCacheHitRate?: number
maxRenderMinutes?: number
}
interface SourceMetrics {
source: string
requests: number
failures: number
timeouts: number
rateLimited: number
cacheHits: number
cacheStale: number
cacheMisses: number
captures: number
renderMs: number
}
function parseArgs(): Options {
const args = process.argv.slice(2)
const opts: Options = {
lastHours: 24,
dataset: 'MEASURE',
json: false,
check: false,
}
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg === '--last' || arg === '-l') opts.lastHours = parseDurationHours(args[++i])
else if (arg === '--worker' || arg === '-w') opts.worker = args[++i]
else if (arg === '--dataset' || arg === '-d') opts.dataset = args[++i]
else if (arg === '--json') opts.json = true
else if (arg === '--check') opts.check = true
else if (arg === '--max-failure-rate') opts.maxFailureRate = Number(args[++i])
else if (arg === '--max-timeout-rate') opts.maxTimeoutRate = Number(args[++i])
else if (arg === '--min-cache-hit-rate') opts.minCacheHitRate = Number(args[++i])
// --max-browser-minutes is the former name; kept as an alias.
else if (arg === '--max-render-minutes' || arg === '--max-browser-minutes')
opts.maxRenderMinutes = Number(args[++i])
else if (arg === '--help' || arg === '-h') {
console.log(
[
'Usage: npx tsx internal/scripts/fetch-screenshot-metrics.ts [options]',
'',
'Options:',
' --last, -l Look-back window, e.g. 1h, 24h, 7d (default: 24h)',
' --worker, -w Filter to one worker name, e.g. main-tldraw-multiplayer',
' --dataset, -d Analytics Engine dataset name (default: MEASURE)',
' --json Print raw per-source metrics as JSON',
' --check Exit non-zero when a threshold below is breached',
' --max-failure-rate Threshold, 0-1 (default with --check: 0.2)',
' --max-timeout-rate Threshold, 0-1 (default with --check: 0.1)',
' --min-cache-hit-rate Threshold, 0-1 (no default)',
' --max-render-minutes Browser Run render minutes in the window (no default)',
' (alias: --max-browser-minutes)',
' --help, -h Show this help',
].join('\n')
)
process.exit(0)
} else {
console.error(`Unknown option: ${arg}`)
process.exit(1)
}
}
if (opts.check) {
opts.maxFailureRate ??= 0.2
opts.maxTimeoutRate ??= 0.1
}
return opts
}
function parseDurationHours(value: string | undefined): number {
const match = value?.match(/^(\d+)([hd])$/)
if (!match) {
console.error(`Invalid duration: ${value}. Use e.g. 6h, 24h, 7d.`)
process.exit(1)
}
const amount = Number(match[1])
return match[2] === 'd' ? amount * 24 : amount
}
function getAuth() {
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID
const apiToken = process.env.CLOUDFLARE_ANALYTICS_API_TOKEN ?? process.env.CLOUDFLARE_API_TOKEN
if (!accountId || !apiToken) {
console.error(
'Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_ANALYTICS_API_TOKEN (or CLOUDFLARE_API_TOKEN).'
)
process.exit(1)
}
return { accountId, apiToken }
}
async function queryAnalyticsEngine(sql: string): Promise<Record<string, string | number>[]> {
const { accountId, apiToken } = getAuth()
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`,
{
method: 'POST',
headers: { Authorization: `Bearer ${apiToken}` },
body: sql,
}
)
if (!response.ok) {
throw new Error(`Analytics Engine query failed (${response.status}): ${await response.text()}`)
}
const result = (await response.json()) as { data: Record<string, string | number>[] }
return result.data
}
// Only [0-9a-zA-Z_-] survives; every dynamic value interpolated into SQL goes through this.
function sqlSafe(value: string) {
return value.replace(/[^0-9a-zA-Z_-]/g, '')
}
async function fetchMetrics(opts: Options): Promise<SourceMetrics[]> {
const workerFilter = opts.worker ? `AND blob2 = '${sqlSafe(opts.worker)}'` : ''
const where = `blob1 = '${EVENT_NAME}' ${workerFilter} AND timestamp > NOW() - INTERVAL '${opts.lastHours}' HOUR`
const dataset = sqlSafe(opts.dataset)
// blob3 = source, blob4 = cache status, blob5 = failure reason, blob6 = rate limit decision.
// _sample_interval corrects for Analytics Engine sampling.
const rows = await queryAnalyticsEngine(`
SELECT
blob3 AS source,
blob4 AS cache,
blob5 AS failure,
blob6 AS rate_limit,
SUM(_sample_interval) AS requests
FROM ${dataset}
WHERE ${where}
GROUP BY source, cache, failure, rate_limit
FORMAT JSON
`)
// double3 is the wall-clock render duration, positive only on rows that actually invoked Browser
// Run (cache hits and pre-render failures leave it at -1). It is the spend proxy: double4 holds
// billed browser ms, which the BROWSER binding does not surface, so it is always -1.
//
// Failed captures count here too, deliberately: a render that threw still created a browser and
// held it, often for the whole timeout, so excluding it would understate spend by exactly the
// renders most worth knowing the cost of. Queue rows are per delivery, so a job that retried
// three times contributes three captures — again, that is what it spent.
const spendRows = await queryAnalyticsEngine(`
SELECT
blob3 AS source,
SUM(_sample_interval) AS captures,
SUM(double3 * _sample_interval) AS render_ms
FROM ${dataset}
WHERE ${where} AND double3 > 0
GROUP BY source
FORMAT JSON
`)
const bySource = new Map<string, SourceMetrics>()
const getSource = (source: string) => {
let metrics = bySource.get(source)
if (!metrics) {
metrics = {
source,
requests: 0,
failures: 0,
timeouts: 0,
rateLimited: 0,
cacheHits: 0,
cacheStale: 0,
cacheMisses: 0,
captures: 0,
renderMs: 0,
}
bySource.set(source, metrics)
}
return metrics
}
for (const row of rows) {
const metrics = getSource(String(row.source).replace(/^source:/, ''))
const requests = Number(row.requests)
const failure = String(row.failure).replace(/^failure:/, '')
const cache = String(row.cache).replace(/^cache:/, '')
metrics.requests += requests
if (failure !== 'none') metrics.failures += requests
if (/timeout|abort/i.test(failure)) metrics.timeouts += requests
if (String(row.rate_limit) === 'rate_limit:blocked') metrics.rateLimited += requests
if (cache === 'hit') metrics.cacheHits += requests
if (cache === 'stale') metrics.cacheStale += requests
if (cache === 'miss') metrics.cacheMisses += requests
}
for (const row of spendRows) {
const metrics = getSource(String(row.source).replace(/^source:/, ''))
metrics.captures += Number(row.captures)
metrics.renderMs += Number(row.render_ms)
}
return [...bySource.values()].sort((a, b) => a.source.localeCompare(b.source))
}
function rate(part: number, total: number) {
return total === 0 ? 0 : part / total
}
function formatPercent(value: number) {
return `${(value * 100).toFixed(1)}%`
}
function main() {
const opts = parseArgs()
return fetchMetrics(opts).then((metrics) => {
if (opts.json) {
console.log(JSON.stringify(metrics, null, 2))
} else {
console.log(
`Screenshot telemetry, last ${opts.lastHours}h${opts.worker ? ` (worker ${opts.worker})` : ''}:`
)
if (metrics.length === 0) console.log(' no events in window')
for (const m of metrics) {
console.log(
[
` ${m.source.padEnd(6)}`,
`requests ${m.requests}`,
`failure ${formatPercent(rate(m.failures, m.requests))}`,
`timeout ${formatPercent(rate(m.timeouts, m.requests))}`,
`cache hit ${formatPercent(rate(m.cacheHits, m.cacheHits + m.cacheStale + m.cacheMisses))}`,
`rate-limited ${m.rateLimited}`,
`captures ${m.captures}`,
`render ${(m.renderMs / 60_000).toFixed(1)}min`,
].join(' ')
)
}
}
if (!opts.check) return
const totals = metrics.reduce(
(sum, m) => ({
requests: sum.requests + m.requests,
failures: sum.failures + m.failures,
timeouts: sum.timeouts + m.timeouts,
cacheHits: sum.cacheHits + m.cacheHits,
cacheLookups: sum.cacheLookups + m.cacheHits + m.cacheStale + m.cacheMisses,
renderMs: sum.renderMs + m.renderMs,
}),
{ requests: 0, failures: 0, timeouts: 0, cacheHits: 0, cacheLookups: 0, renderMs: 0 }
)
const breaches: string[] = []
const failureRate = rate(totals.failures, totals.requests)
const timeoutRate = rate(totals.timeouts, totals.requests)
const cacheHitRate = rate(totals.cacheHits, totals.cacheLookups)
const renderMinutes = totals.renderMs / 60_000
if (opts.maxFailureRate !== undefined && failureRate > opts.maxFailureRate) {
breaches.push(
`failure rate ${formatPercent(failureRate)} > ${formatPercent(opts.maxFailureRate)}`
)
}
if (opts.maxTimeoutRate !== undefined && timeoutRate > opts.maxTimeoutRate) {
breaches.push(
`timeout rate ${formatPercent(timeoutRate)} > ${formatPercent(opts.maxTimeoutRate)}`
)
}
if (
opts.minCacheHitRate !== undefined &&
totals.cacheLookups > 0 &&
cacheHitRate < opts.minCacheHitRate
) {
breaches.push(
`cache hit rate ${formatPercent(cacheHitRate)} < ${formatPercent(opts.minCacheHitRate)}`
)
}
if (opts.maxRenderMinutes !== undefined && renderMinutes > opts.maxRenderMinutes) {
breaches.push(
`Browser Run render time ${renderMinutes.toFixed(1)}min > ${opts.maxRenderMinutes}min`
)
}
if (breaches.length > 0) {
console.error(`ALERT: ${breaches.join('; ')}`)
process.exit(1)
}
console.log('All thresholds OK')
})
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exit(1)
})

View File

@@ -0,0 +1,38 @@
/**
* Shared utilities for Fly.io scripts.
*
* App names:
* production-zero-rm Production replication manager
* production-zero-vs Production view syncer
* staging-zero-rm Staging replication manager
* staging-zero-vs Staging view syncer
* pr-NNNN-zero-cache PR preview (single zero-cache process)
*/
export const FLY_ORG_SLUG = process.env.FLY_ORG_SLUG ?? 'tldraw-gb-ltd'
export function getFlyToken(explicit?: string): string {
let token = explicit ?? process.env.FLY_TOKEN
if (!token) {
console.error(
'No token provided. Use --token, FLY_TOKEN env var, or create one with: fly tokens create readonly'
)
process.exit(1)
}
// Strip "FlyV1 " prefix if present — callers add it themselves
if (token.startsWith('FlyV1 ')) token = token.slice(6)
return token
}
export function parseDuration(s: string, unit: 'ms' | 's' = 's'): number {
const match = s.match(/^(\d+)(m|h|d)$/)
if (!match) {
console.error(`Invalid duration: ${s}. Use e.g. 30m, 2h, 1d`)
process.exit(1)
}
const n = parseInt(match[1])
const u = match[2]
const multipliers: Record<string, number> = { m: 60, h: 3_600, d: 86_400 }
const sec = n * multipliers[u]
return unit === 'ms' ? sec * 1000 : sec
}

View File

@@ -0,0 +1,257 @@
#!/usr/bin/env npx tsx
/**
* Test License Key Generator
*
* This script generates various license keys for manual testing of the LicenseManager.
* Uses the existing test utilities from LicenseManager.test.ts
*/
import crypto from 'crypto'
import { FLAGS } from '../../packages/editor/src/lib/license/LicenseManager'
import { str2ab } from '../../packages/editor/src/lib/utils/licensing'
// Utility function copied from LicenseManager.test.ts to avoid import issues
function ab2str(buf: ArrayBuffer) {
return String.fromCharCode.apply(null, Array.from(new Uint8Array(buf)))
}
// Utility functions copied from LicenseManager.test.ts (not exported)
async function generateLicenseKey(
message: string,
keyPair: { publicKey: string; privateKey: string }
) {
const enc = new TextEncoder()
const encodedMsg = enc.encode(message)
const privateKey = await importPrivateKey(keyPair.privateKey)
const signedLicenseKeyBuffer = await crypto.subtle.sign(
{
name: 'ECDSA',
hash: { name: 'SHA-256' },
},
privateKey,
encodedMsg
)
const signature = btoa(ab2str(signedLicenseKeyBuffer))
const prefix = 'tldraw-'
const licenseKey = `${prefix}/${btoa(message)}.${signature}`
return licenseKey
}
function importPrivateKey(pemContents: string) {
const binaryDerString = atob(pemContents)
const binaryDer = str2ab(binaryDerString)
return crypto.subtle.importKey(
'pkcs8',
new Uint8Array(binaryDer),
{
name: 'ECDSA',
namedCurve: 'P-256',
},
true,
['sign']
)
}
async function exportCryptoKey(key: CryptoKey, isPublic = false) {
const exported = await crypto.subtle.exportKey(isPublic ? 'spki' : 'pkcs8', key)
return btoa(ab2str(exported))
}
// Generate a fresh key pair for each run
async function generateKeyPair() {
const keyPair = await crypto.subtle.generateKey(
{
name: 'ECDSA',
namedCurve: 'P-256',
},
true,
['sign', 'verify']
)
const publicKey = await exportCryptoKey(keyPair.publicKey, true /* isPublic */)
const privateKey = await exportCryptoKey(keyPair.privateKey)
return { publicKey, privateKey }
}
function createLicenseInfo(id: string, hosts: string[], flags: number, expiryDate: string) {
return JSON.stringify([id, hosts, flags, expiryDate])
}
function getDateOffset(days: number): string {
const date = new Date()
date.setDate(date.getDate() + days)
return date.toISOString().slice(0, 10)
}
async function main() {
console.log('🔑 Generating test license keys for LicenseManager testing...\n')
console.log('Generating fresh key pair for testing...')
const keyPair = await generateKeyPair()
console.log('Public Key:', keyPair.publicKey)
console.log('Private Key:', keyPair.privateKey)
console.log('')
const testCases = [
// === VALID LICENSES ===
{
name: 'Valid Annual License',
info: createLicenseInfo(
'test-annual-valid',
['localhost'],
FLAGS.ANNUAL_LICENSE,
getDateOffset(60) // 2 months future
),
},
{
name: 'Valid Perpetual License',
info: createLicenseInfo(
'test-perpetual-valid',
['localhost'],
FLAGS.PERPETUAL_LICENSE,
getDateOffset(365) // 1 year future
),
},
{
name: 'Valid Evaluation License',
info: createLicenseInfo(
'test-eval-valid',
['localhost'],
FLAGS.EVALUATION_LICENSE,
getDateOffset(14) // 2 weeks future
),
},
{
name: 'Valid Internal License',
info: createLicenseInfo(
'test-internal-valid',
['localhost'],
FLAGS.ANNUAL_LICENSE | FLAGS.INTERNAL_LICENSE,
getDateOffset(60) // 2 months future
),
},
{
name: 'License with Watermark Flag',
info: createLicenseInfo(
'test-watermark-only',
['localhost'],
FLAGS.WITH_WATERMARK,
getDateOffset(60) // 2 months future
),
},
// === FEATURE FLAGS ===
{
name: 'License with Commenting Feature',
info: createLicenseInfo(
'test-feat-commenting',
['localhost'],
FLAGS.ANNUAL_LICENSE | FLAGS.FEAT_COMMENTING,
getDateOffset(60) // 2 months future
),
},
{
name: 'License with Collaboration Feature (grants commenting)',
info: createLicenseInfo(
'test-feat-collaboration',
['localhost'],
FLAGS.ANNUAL_LICENSE | FLAGS.FEAT_COLLABORATION,
getDateOffset(60) // 2 months future
),
},
// === GRACE PERIOD TESTING ===
{
name: 'Annual License (expired 15 days - within grace period)',
info: createLicenseInfo(
'test-grace-15days',
['localhost'],
FLAGS.ANNUAL_LICENSE,
getDateOffset(-15) // 15 days past
),
},
{
name: 'Annual License (expired 45 days - beyond grace period)',
info: createLicenseInfo(
'test-annual-expired-45days',
['localhost'],
FLAGS.ANNUAL_LICENSE,
getDateOffset(-45) // 45 days past
),
},
// === EXPIRED INTERNAL LICENSES ===
{
name: 'Internal License (expired 15 days - within grace period)',
info: createLicenseInfo(
'test-internal-expired-15days',
['localhost'],
FLAGS.ANNUAL_LICENSE | FLAGS.INTERNAL_LICENSE,
getDateOffset(-15) // 15 days past
),
},
{
name: 'Internal License (expired 45 days - beyond grace period)',
info: createLicenseInfo(
'test-internal-expired-45days',
['localhost'],
FLAGS.ANNUAL_LICENSE | FLAGS.INTERNAL_LICENSE,
getDateOffset(-45) // 45 days past
),
},
// === EXPIRED EVALUATION LICENSES ===
{
name: 'Evaluation License (expired 1 day)',
info: createLicenseInfo(
'test-eval-expired-1day',
['localhost'],
FLAGS.EVALUATION_LICENSE,
getDateOffset(-1) // 1 day past
),
},
{
name: 'Evaluation License (expired 15 days)',
info: createLicenseInfo(
'test-eval-expired-15days',
['localhost'],
FLAGS.EVALUATION_LICENSE,
getDateOffset(-15) // 15 days past
),
},
]
console.log('🎫 Generated Test License Keys:\n')
for (const testCase of testCases) {
try {
const licenseKey = await generateLicenseKey(testCase.info, keyPair)
console.log(`## ${testCase.name}`)
console.log(`License Info: ${testCase.info}`)
console.log(`License Key: ${licenseKey}`)
console.log('')
} catch (error) {
console.error(`❌ Failed to generate license for ${testCase.name}:`, error)
}
}
console.log('📝 Usage Instructions:')
console.log('')
console.log('🚨 DO NOT COMMIT THESE KEYS TO THE REPOSITORY! 🚨')
console.log('These are temporary test keys - generate fresh ones for each session')
console.log('')
console.log('Option 1 - Update LicenseManager (development):')
console.log(` Replace publicKey in LicenseManager.ts with: "${keyPair.publicKey}"`)
console.log(' Then use: <Tldraw licenseKey="license-key-from-above" />')
console.log('')
console.log('Option 2 - Pass testPublicKey (automated testing):')
console.log(` new LicenseManager("license-key", "${keyPair.publicKey}", "production")`)
console.log('')
console.log('ℹ️ For production licenses: sales@tldraw.com')
}
main().catch(console.error)

View File

@@ -0,0 +1,334 @@
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
import path from 'path'
import { fileURLToPath, pathToFileURL } from 'url'
const DOCS_SECTION_IDS = ['getting-started', 'docs', 'starter-kits', 'sdk-features', 'community']
const RELEASES_SECTION_ID = 'releases'
const RELEASE_NOTES_FILE_NAME = /^v\d+\.\d+\.\d+\.mdx$/
const NEXT_RELEASE_NOTES_FILE_NAME = 'next.mdx'
const UNWRAPPED_MDX_TAGS = new Set(['Callout', 'Feature', 'FocusLines', 'div'])
const OMITTED_MDX_TAGS = new Set(['Image', 'MaskedArticleImage', 'StarterKitBento', 'img'])
interface Article {
content: string
fileName: string
order: number | null
sectionId: string
title: string
version: [number, number, number] | null
}
interface Section {
id: string
title: string
description?: string
}
export function generateTldrawPackageDocs({
sourcePackageDir,
repoRoot = path.resolve(sourcePackageDir, '../..'),
}: {
sourcePackageDir: string
repoRoot?: string
}) {
const packageJsonPath = path.join(sourcePackageDir, 'package.json')
if (!existsSync(packageJsonPath)) {
throw new Error(`No package.json file found in '${sourcePackageDir}'!`)
}
const manifest = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
const packageVersion = manifest.version
const contentDir = path.join(repoRoot, 'apps/docs/content')
const sections = readSections(contentDir)
writeFileSync(
path.join(sourcePackageDir, 'DOCS.md'),
createDocsRollup({ contentDir, packageVersion, sections })
)
writeFileSync(
path.join(sourcePackageDir, 'RELEASE_NOTES.md'),
createReleaseNotesRollup({ contentDir, packageVersion, sections })
)
}
export function createDocsRollup({
contentDir,
packageVersion,
sections,
}: {
contentDir: string
packageVersion: string
sections: Section[]
}) {
const result = [
'# tldraw Documentation',
'',
`Version: \`${packageVersion}\``,
'',
'This file is generated during package publishing from the tldraw docs content for this exact package version.',
]
for (const sectionId of DOCS_SECTION_IDS) {
const section = sectionById(sections, sectionId)
const articles = readArticles(contentDir, sectionId)
if (!articles.length) continue
result.push('', `## ${section.title}`)
if (section.description) result.push('', section.description)
for (const article of articles) {
result.push('', `### ${article.title}`, '', normalizeMdxForMarkdown(article.content, 2))
}
}
return result.join('\n').trimEnd() + '\n'
}
export function createReleaseNotesRollup({
contentDir,
packageVersion,
sections,
}: {
contentDir: string
packageVersion: string
sections: Section[]
}) {
const section = sectionById(sections, RELEASES_SECTION_ID)
const releaseNotes = readReleaseNotesArticles(contentDir, packageVersion)
.filter((article): article is Article & { version: [number, number, number] } =>
Boolean(article.version)
)
.sort((a, b) => compareVersions(b.version, a.version))
const result = [
`# ${section.title}`,
'',
`Version: \`${packageVersion}\``,
'',
'This file is generated during package publishing from the tldraw release notes content for this exact package version.',
]
for (const article of releaseNotes) {
result.push('', `## ${article.title}`, '', normalizeMdxForMarkdown(article.content, 1))
}
return result.join('\n').trimEnd() + '\n'
}
function readReleaseNotesArticles(contentDir: string, packageVersion: string) {
const releaseNotes = readArticles(contentDir, RELEASES_SECTION_ID)
const packageVersionTuple = getStableVersionTuple(packageVersion)
if (packageVersionTuple) {
const packageVersionTitle = `v${packageVersionTuple.join('.')}`
const nextReleaseNotes = readArticle(
path.join(contentDir, RELEASES_SECTION_ID, NEXT_RELEASE_NOTES_FILE_NAME),
RELEASES_SECTION_ID,
NEXT_RELEASE_NOTES_FILE_NAME
)
if (
!releaseNotes.some((article) => article.title === packageVersionTitle) &&
nextReleaseNotes
) {
releaseNotes.push({
...nextReleaseNotes,
fileName: `${packageVersionTitle}.mdx`,
title: packageVersionTitle,
version: packageVersionTuple,
})
}
}
return releaseNotes
}
function readSections(contentDir: string): Section[] {
return JSON.parse(readFileSync(path.join(contentDir, 'sections.json'), 'utf8'))
}
function sectionById(sections: Section[], sectionId: string) {
const section = sections.find((section) => section.id === sectionId)
if (!section) {
throw new Error(`Could not find docs section '${sectionId}'`)
}
return section
}
function readArticles(contentDir: string, sectionId: string) {
const sectionDir = path.join(contentDir, sectionId)
return readdirSync(sectionDir)
.filter((fileName) => fileName.endsWith('.mdx') || fileName.endsWith('.md'))
.filter(
(fileName) => sectionId !== RELEASES_SECTION_ID || RELEASE_NOTES_FILE_NAME.test(fileName)
)
.map((fileName) => readArticle(path.join(sectionDir, fileName), sectionId, fileName))
.filter((article) => article !== null)
.sort(sortArticles)
}
function readArticle(filePath: string, sectionId: string, fileName: string): Article | null {
const { data, content } = parseFrontMatter(readFileSync(filePath, 'utf8'))
if (data.status !== 'published') return null
const title = data.title ?? path.basename(fileName, path.extname(fileName))
const order = data.order ? Number(data.order) : null
const versionMatch = fileName.match(/^v(\d+)\.(\d+)\.(\d+)\.mdx$/)
return {
content,
fileName,
order: Number.isFinite(order) ? order : null,
sectionId,
title,
version: versionMatch
? [Number(versionMatch[1]), Number(versionMatch[2]), Number(versionMatch[3])]
: null,
}
}
function parseFrontMatter(raw: string): { data: Record<string, string>; content: string } {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
if (!match) return { data: {}, content: raw }
const data: Record<string, string> = {}
for (const line of match[1].split(/\r?\n/)) {
const lineMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
if (!lineMatch) continue
const [, key, rawValue] = lineMatch
let value = rawValue.trim()
if (
(value.startsWith("'") && value.endsWith("'")) ||
(value.startsWith('"') && value.endsWith('"'))
) {
value = value.slice(1, -1)
}
data[key] = value
}
return { data, content: raw.slice(match[0].length).trim() }
}
function sortArticles(a: Article, b: Article) {
const orderA = a.order ?? Number.MAX_SAFE_INTEGER
const orderB = b.order ?? Number.MAX_SAFE_INTEGER
if (orderA !== orderB) return orderA - orderB
return a.title.localeCompare(b.title)
}
function compareVersions(a: [number, number, number], b: [number, number, number]) {
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]
}
function getStableVersionTuple(packageVersion: string): [number, number, number] | null {
const match = packageVersion.match(/^(\d+)\.(\d+)\.(\d+)$/)
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null
}
function normalizeMdxForMarkdown(content: string, headingDemotion: number) {
const result: string[] = []
let inFence = false
let omittedMdxTag: string | null = null
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim()
if (/^(```|~~~)/.test(trimmed)) {
inFence = !inFence
result.push(line)
continue
}
if (!inFence) {
if (omittedMdxTag) {
if (trimmed.includes('/>') || trimmed.includes(`</${omittedMdxTag}>`)) {
omittedMdxTag = null
}
continue
}
if (/^import\s.+\sfrom\s/.test(trimmed)) continue
if (/^export\s+(const|function|class|type|interface|default|\{)/.test(trimmed)) continue
const mdxLine = normalizeMdxLine(trimmed)
if (mdxLine.kind === 'omit') {
omittedMdxTag = mdxLine.tag
continue
}
if (mdxLine.kind === 'skip') continue
if (mdxLine.kind === 'replace') {
result.push(rewriteLinks(demoteHeading(mdxLine.content, headingDemotion)))
continue
}
result.push(rewriteLinks(demoteHeading(line, headingDemotion)))
} else {
result.push(line)
}
}
return result
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function normalizeMdxLine(
trimmed: string
):
| { kind: 'keep' }
| { kind: 'skip' }
| { kind: 'omit'; tag: string }
| { kind: 'replace'; content: string } {
const checkItemMatch = trimmed.match(/^<CheckItem>(.*?)<\/CheckItem>$/)
if (checkItemMatch) {
return { kind: 'replace', content: `- ${checkItemMatch[1]}` }
}
const tagName = getMdxTagName(trimmed)
if (!tagName) return { kind: 'keep' }
if (tagName === 'br') return { kind: 'skip' }
if (OMITTED_MDX_TAGS.has(tagName)) {
return trimmed.includes('/>') || trimmed.includes(`</${tagName}>`)
? { kind: 'skip' }
: { kind: 'omit', tag: tagName }
}
if (UNWRAPPED_MDX_TAGS.has(tagName)) {
const title = tagName === 'Feature' ? getMdxStringProp(trimmed, 'title') : null
return title ? { kind: 'replace', content: `**${title}**` } : { kind: 'skip' }
}
if (/^<\/?[A-Z][A-Za-z0-9.]*\b/.test(trimmed)) {
return { kind: 'skip' }
}
return { kind: 'keep' }
}
function getMdxTagName(trimmed: string) {
const match = trimmed.match(/^<\/?([A-Za-z][A-Za-z0-9.]*)\b/)
return match?.[1] ?? null
}
function getMdxStringProp(trimmed: string, propName: string) {
const match = trimmed.match(new RegExp(`\\b${propName}=(?:"([^"]+)"|'([^']+)')`))
return match?.[1] ?? match?.[2] ?? null
}
function demoteHeading(line: string, headingDemotion: number) {
return line.replace(/^(#{1,6})(\s+)/, (_, hashes, spacing) => {
return '#'.repeat(Math.min(6, hashes.length + headingDemotion)) + spacing
})
}
function rewriteLinks(line: string) {
return line
.replace(/\[`?([^[\]]*?)`?\]\(\?\)/g, '`$1`')
.replace(/\]\((\/[^)\s]+)\)/g, '](https://tldraw.dev$1)')
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
generateTldrawPackageDocs({
sourcePackageDir: path.resolve(path.normalize(process.argv[2] ?? process.cwd())),
repoRoot: path.resolve(scriptDir, '../..'),
})
}

View File

@@ -0,0 +1,219 @@
import fs from 'fs'
import path from 'path'
import JSZip from 'jszip'
const LOKALISE_API_BASE = 'https://api.lokalise.com/api2'
const ASYNC_EXPORT_POLL_INTERVAL_MS = 2000
const ASYNC_EXPORT_MAX_POLLS = 120
function formatError(error: unknown) {
if (error instanceof Error) {
return error.stack ?? error.message
}
try {
return JSON.stringify(error, null, 2)
} catch {
return String(error)
}
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function getEnv(name: string) {
const value = process.env[name]
if (!value) throw new Error(`Missing required env var: ${name}`)
return value
}
async function lokaliseRequest<T>(path: string, init: RequestInit, apiKey: string): Promise<T> {
const response = await fetch(`${LOKALISE_API_BASE}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
'X-Api-Token': apiKey,
...(init.headers ?? {}),
},
})
const body = (await response.json()) as unknown
if (!response.ok) {
throw new Error(
`Lokalise request failed (${response.status}): ${JSON.stringify(body, null, 2)}`
)
}
return body as T
}
async function getAsyncBundleUrl(
projectId: string,
options: Record<string, unknown>,
apiKey: string
) {
const start = await lokaliseRequest<{ process_id?: string }>(
`/projects/${projectId}/files/async-download`,
{
method: 'POST',
body: JSON.stringify(options),
},
apiKey
)
if (!start.process_id) {
throw new Error(`Async export did not return process_id: ${JSON.stringify(start, null, 2)}`)
}
for (let i = 0; i < ASYNC_EXPORT_MAX_POLLS; i++) {
const processResult = await lokaliseRequest<{
process?: {
status?: string
message?: string
details?: { download_url?: string; bundle_url?: string }
}
}>(`/projects/${projectId}/processes/${start.process_id}`, { method: 'GET' }, apiKey)
const status = processResult.process?.status
const details = processResult.process?.details
const bundleUrl = details?.download_url ?? details?.bundle_url
if (status === 'finished') {
if (!bundleUrl) {
throw new Error(
`Async export finished but no bundle URL was returned: ${JSON.stringify(processResult, null, 2)}`
)
}
return bundleUrl
}
if (status === 'failed' || status === 'cancelled') {
throw new Error(
`Async export ${status}: ${processResult.process?.message ?? 'No message'}\n${JSON.stringify(processResult, null, 2)}`
)
}
await sleep(ASYNC_EXPORT_POLL_INTERVAL_MS)
}
throw new Error(
`Async export timed out after ${ASYNC_EXPORT_MAX_POLLS} polls for project ${projectId}`
)
}
async function fetchAndSave(
url: string,
dirPath: string,
sortJson: boolean,
fileResolver: (fileName: string) => string,
space: string | number,
skipEnglish = false
) {
const bundleZip = await fetch(url)
// Need to unzip the bundle
const zipData = await bundleZip.arrayBuffer()
const zip = new JSZip()
const bundle = await zip.loadAsync(zipData)
const locales = Object.keys(bundle.files)
for (const locale of locales) {
if (bundle.files[locale].dir) {
continue
}
const fileName = fileResolver(bundle.files[locale].name)
if (skipEnglish && fileName === 'en.json') {
continue
}
const filePath = path.resolve(dirPath, fileName)
const file = await bundle.files[locale].async('text')
const json = JSON.parse(file)
let finalJson = json
if (sortJson) {
// Sort of annoying, the property order is not guaranteed, so we need to alphabetize the keys.
const sortedJson: { [key: string]: Record<string, string> } = {}
// We have to go into the subobject to sort the keys.
for (const key of Object.keys(json)) {
const subJson = json[key]
const sortedSubJson: Record<string, string> = {}
for (const subKey of Object.keys(subJson).sort()) {
sortedSubJson[subKey] = subJson[subKey]
}
sortedJson[key] = sortedSubJson
}
finalJson = sortedJson
}
fs.writeFileSync(filePath, JSON.stringify(finalJson, null, space) + '\n')
console.log(`Wrote ${filePath}`)
}
}
async function i18nDownloadStrings() {
await i18nDownloadTldrawStrings()
await i18nDownloadDotcomStrings()
}
async function i18nDownloadTldrawStrings() {
const projectId = getEnv('LOKALISE_TLDRAW_PROJECT_ID')
const apiKey = getEnv('LOKALISE_API_TOKEN')
const dirPath = path.resolve(__dirname, '../../assets/translations')
console.log('Downloading tldraw project files...')
const bundleUrl = await getAsyncBundleUrl(
projectId,
{
format: 'json',
original_filenames: true,
export_empty_as: 'skip',
},
apiKey
)
await fetchAndSave(
bundleUrl,
dirPath,
false /* sort json */,
(fileName) => fileName.split('/')[0].replace('_', '-').toLowerCase() + '.json',
'\t',
true /* skip english */
)
console.log('Downloaded files successfully!')
}
async function i18nDownloadDotcomStrings() {
const projectId = getEnv('LOKALISE_PROJECT_ID')
const apiKey = getEnv('LOKALISE_API_TOKEN')
const dirPath = path.resolve(__dirname, '../../apps/dotcom/client/public/tla/locales')
console.log('Downloading dotcom project files...')
const bundleUrl = await getAsyncBundleUrl(
projectId,
{
format: 'json_structured',
original_filenames: true,
include_comments: true,
include_description: true,
export_empty_as: 'skip',
plural_format: 'icu',
placeholder_format: 'icu',
},
apiKey
)
await fetchAndSave(
bundleUrl,
dirPath,
true /* sort json */,
(fileName) => fileName.split('/')[1].replace('_', '-').toLowerCase(),
2
)
console.log('Downloaded files successfully!')
}
i18nDownloadStrings().catch((error) => {
console.error('Failed to download i18n strings:')
console.error(formatError(error))
process.exit(1)
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,253 @@
import fs from 'fs'
import path from 'path'
import { LokaliseApi, QueuedProcess } from '@lokalise/node-api'
// Pushes the English source strings to Lokalise and orders machine translations
// for anything still untranslated (see i18n-download-strings.ts for the other
// half of the pipeline).
//
// Two Lokalise projects are in play:
// - the dotcom project (LOKALISE_PROJECT_ID), source apps/dotcom/client/public/tla/locales/en.json
// - the tldraw SDK project (LOKALISE_TLDRAW_PROJECT_ID), source assets/translations/main.json
// Only the dotcom project orders translations automatically; SDK orders are
// still placed by hand.
const UPLOAD_POLL_INTERVAL_MS = 2000
const UPLOAD_MAX_POLLS = 120
/** Items per page when listing keys and languages, within Lokalise's per-endpoint maximums. */
const PAGE_LIMIT = 500
/** Anything pricier than this wants a human to look at it first. */
const MAX_ORDER_TOTAL = 10
function formatError(error: unknown) {
if (error instanceof Error) {
return error.stack ?? error.message
}
try {
return JSON.stringify(error, null, 2)
} catch {
return String(error)
}
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function getEnv(name: string) {
const value = process.env[name]
if (!value) throw new Error(`Missing required env var: ${name}`)
return value
}
/**
* Lokalise's file upload is asynchronous: it returns a queued process and
* creates the keys in the background. Poll until it's done — otherwise anything
* we ask about the project next still describes the state from before the
* upload, and freshly added strings look like they don't exist.
*/
async function waitForUpload(lokaliseApi: LokaliseApi, projectId: string, processId: string) {
for (let i = 0; i < UPLOAD_MAX_POLLS; i++) {
const queuedProcess = await lokaliseApi
.queuedProcesses()
.get(processId, { project_id: projectId })
if (queuedProcess.status === 'finished') return queuedProcess
if (queuedProcess.status === 'failed' || queuedProcess.status === 'cancelled') {
throw new Error(
`Upload ${queuedProcess.status}: ${queuedProcess.message || 'No message'}\n${JSON.stringify(queuedProcess, null, 2)}`
)
}
await sleep(UPLOAD_POLL_INTERVAL_MS)
}
throw new Error(`Upload timed out after ${UPLOAD_MAX_POLLS} polls for project ${projectId}`)
}
function logUploadResult(queuedProcess: QueuedProcess) {
const files = queuedProcess.details?.files ?? []
if (!files.length) {
console.log(' Upload finished, but Lokalise reported no file details.')
return
}
for (const file of files) {
console.log(
` ${file.name_original ?? 'file'}: ${file.key_count_inserted ?? 0} inserted, ` +
`${file.key_count_updated ?? 0} updated, ${file.key_count_skipped ?? 0} skipped ` +
`(${file.key_count_total ?? 0} total).`
)
}
}
async function uploadFile(
lokaliseApi: LokaliseApi,
options: { projectId: string; filePath: string; filename: string; cleanupMode: boolean }
) {
const { projectId, filePath, filename, cleanupMode } = options
const file = fs.readFileSync(filePath, 'utf8')
console.log(`Uploading ${filename}...`)
const uploadResult = await lokaliseApi.files().upload(projectId, {
data: Buffer.from(file).toString('base64'),
filename,
lang_iso: 'en',
detect_icu_plurals: true,
cleanup_mode: cleanupMode,
})
if (!uploadResult.process_id) {
throw new Error(`Upload did not return a process_id: ${JSON.stringify(uploadResult, null, 2)}`)
}
const queuedProcess = await waitForUpload(lokaliseApi, projectId, uploadResult.process_id)
console.log(`Uploaded ${filename} successfully!`)
logUploadResult(queuedProcess)
}
/** Every target language in the project, English excluded. Paginated: there are 50+ of them. */
async function listTargetLanguageIsos(lokaliseApi: LokaliseApi, projectId: string) {
const isos: string[] = []
for (let page = 1; ; page++) {
const result = await lokaliseApi
.languages()
.list({ project_id: projectId, limit: PAGE_LIMIT, page })
isos.push(...result.items.map((item) => item.lang_iso))
if (result.items.length < PAGE_LIMIT) break
}
return isos.filter((lang) => lang !== 'en')
}
/** Every key with at least one missing translation. Paginated: a big batch would otherwise truncate. */
async function listUntranslatedKeyIds(lokaliseApi: LokaliseApi, projectId: string) {
const keyIds: number[] = []
let cursor: string | undefined
do {
const result = await lokaliseApi.keys().list({
project_id: projectId,
filter_untranslated: 1,
pagination: 'cursor',
limit: PAGE_LIMIT,
...(cursor ? { cursor } : {}),
})
keyIds.push(...result.items.map((item) => item.key_id))
cursor = result.hasNextCursor() ? (result.nextCursor ?? undefined) : undefined
} while (cursor)
return keyIds
}
async function orderDotcomTranslations(lokaliseApi: LokaliseApi, projectId: string) {
const teamId = getEnv('LOKALISE_TEAM_ID')
const cardId = getEnv('LOKALISE_CARD_ID')
const targetLanguageIsos = await listTargetLanguageIsos(lokaliseApi, projectId)
const allProjectUntranslatedKeys = await listUntranslatedKeyIds(lokaliseApi, projectId)
if (!allProjectUntranslatedKeys.length) {
console.log('No new strings to translate.')
return
}
console.log(
`Found ${allProjectUntranslatedKeys.length} untranslated key(s) across ${targetLanguageIsos.length} language(s).`
)
const orderDetails = {
project_id: projectId,
payment_method: 'credit_card' as const,
card_id: String(cardId),
briefing: 'Please translate the following keys',
source_language_iso: 'en',
target_language_isos: targetLanguageIsos,
keys: allProjectUntranslatedKeys,
provider_slug: 'google',
translation_tier: 1,
}
console.log('Placing test order for new strings...')
const placeTranslationOrderDryRun = await lokaliseApi.orders().create(
{
...orderDetails,
dry_run: true,
},
{
team_id: teamId,
}
)
// Lokalise won't accept an order that prices out at 0, so there's nothing to
// place here. Log enough to diagnose it: the usual cause is that the keys are
// untranslated but not billable (already covered by translation memory, or
// sitting in an open order from a previous run).
if (placeTranslationOrderDryRun.total === 0) {
console.log(
`There are strings to translate but the dry run priced them at 0, so Lokalise won't take the order.`
)
console.log(
` Untranslated keys (${allProjectUntranslatedKeys.length}): ${allProjectUntranslatedKeys.join(', ')}`
)
console.log(
` Target languages (${targetLanguageIsos.length}): ${targetLanguageIsos.join(', ')}`
)
console.log(` Dry run response: ${JSON.stringify(placeTranslationOrderDryRun, null, 2)}`)
console.log('Place the order manually in Lokalise if these strings need translating.')
return
}
if (placeTranslationOrderDryRun.total > MAX_ORDER_TOTAL) {
console.error(
`Cost of translations (${placeTranslationOrderDryRun.total}) is exceeding expectations (${MAX_ORDER_TOTAL}). Place a manual order.`
)
process.exit(1)
}
console.log(
`Placing actual order for new strings (total: ${placeTranslationOrderDryRun.total})...`
)
await lokaliseApi.orders().create(
{
...orderDetails,
},
{
team_id: teamId,
}
)
console.log('Finished placing order for new strings.')
}
async function i18nUploadStrings() {
const lokaliseApi = new LokaliseApi({ apiKey: getEnv('LOKALISE_API_TOKEN') })
const dotcomProjectId = getEnv('LOKALISE_PROJECT_ID')
const tldrawProjectId = getEnv('LOKALISE_TLDRAW_PROJECT_ID')
await uploadFile(lokaliseApi, {
projectId: dotcomProjectId,
filePath: path.resolve(__dirname, '../../apps/dotcom/client/public/tla/locales/en.json'),
filename: 'en.json',
cleanupMode: true,
})
await orderDotcomTranslations(lokaliseApi, dotcomProjectId)
// The SDK project has no automated ordering — uploading keeps its source
// strings current so a manual order can pick them up. It runs last so a
// problem here can't stop the dotcom half of the pipeline.
// Cleanup mode deletes keys missing from the uploaded file, which is what
// keeps the project matching main.json. Both projects export with
// original_filenames, and i18n-download-strings.ts maps every file in a
// locale's folder to the same <locale>.json, so a stray file here doesn't
// show up as extra keys — it silently overwrites a locale's real strings
// depending on export order.
await uploadFile(lokaliseApi, {
projectId: tldrawProjectId,
filePath: path.resolve(__dirname, '../../assets/translations/main.json'),
filename: 'main.json',
cleanupMode: true,
})
}
i18nUploadStrings().catch((error) => {
console.error('Failed to upload i18n strings:')
console.error(formatError(error))
process.exit(1)
})

View File

@@ -0,0 +1,77 @@
import { existsSync, readFileSync, statSync, writeFileSync } from 'fs'
import path from 'path'
import { glob } from 'glob'
import { parse, print, visit } from 'recast'
const extensions = ['.js', '.mjs', '.cjs']
function resolveRelativePath(importingFile: string, relativePath: string) {
if (!relativePath.startsWith('.')) {
return relativePath
}
const containingDir = path.dirname(importingFile)
if (
existsSync(path.join(containingDir, relativePath)) &&
!statSync(path.join(containingDir, relativePath)).isDirectory()
) {
// if the file already exists, e.g. .css files, just use it
return relativePath
}
// strip the file extension if applicable
relativePath.replace(/\.(m|c)?js$/, '')
for (const extension of extensions) {
if (relativePath.endsWith(extension)) {
return relativePath
} else {
let candidate = `${relativePath}${extension}`
if (existsSync(path.join(containingDir, candidate))) {
return candidate
}
candidate = `${relativePath}/index${extension}`
if (existsSync(path.join(containingDir, candidate))) {
return candidate
}
}
}
throw new Error(`Could not resolve relative path ${relativePath} from ${importingFile}`)
}
export function addJsExtensions(distDir: string) {
for (const file of glob.sync(path.join(distDir, '**/*.{mjs,cjs,js}'))) {
const code = parse(readFileSync(file, 'utf8'), { parser: require('recast/parsers/typescript') })
visit(code, {
visitImportDeclaration(path) {
path.value.source.value = resolveRelativePath(file, path.value.source.value)
return false
},
visitExportAllDeclaration(path) {
path.value.source.value = resolveRelativePath(file, path.value.source.value)
return false
},
visitExportNamedDeclaration(path) {
if (path.value.source) {
path.value.source.value = resolveRelativePath(file, path.value.source.value)
}
this.traverse(path)
},
visitCallExpression(path) {
if (
path.value.callee.type === 'Import' &&
path.value.arguments[0]?.type === 'StringLiteral'
) {
path.value.arguments[0].value = resolveRelativePath(file, path.value.arguments[0].value)
}
this.traverse(path)
},
})
writeFileSync(file, print(code).code)
}
}

View File

@@ -0,0 +1,217 @@
import { readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import * as github from '@actions/github'
import { exec } from './exec'
export function getDeployInfo() {
const githubPrNumber = process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\/merge/)?.[1]
let previewId = process.env.TLDRAW_PREVIEW_ID
if (!previewId && process.env.TLDRAW_ENV === 'preview') {
if (githubPrNumber) {
previewId = `pr-${githubPrNumber}`
} else {
throw new Error(
'If running preview deploys from outside of a PR action, TLDRAW_PREVIEW_ID env var must be set'
)
}
}
const sha: string | undefined =
// if the event is 'pull_request', github.context.sha is an ephemeral merge commit
// while the actual commit we want to create the deployment for is the 'head' of the PR.
github.context.eventName === 'pull_request'
? github.context.payload.pull_request?.head.sha
: github.context.sha
if (!sha) {
throw new Error('Could not determine the SHA of the commit to deploy')
}
return {
githubPrNumber,
previewId,
sha,
}
}
// Creates a github 'deployment', which creates a 'View Deployment' button in the PR timeline.
export async function createGithubDeployment(
env: { GH_TOKEN: string; TLDRAW_ENV: string },
{
app,
deploymentUrl,
inspectUrl,
sha,
}: { app: string; deploymentUrl: string; inspectUrl?: string; sha: string }
) {
const client = github.getOctokit(env.GH_TOKEN)
const deployment = await client.rest.repos.createDeployment({
owner: 'tldraw',
repo: 'tldraw',
ref: sha,
payload: { web_url: deploymentUrl },
environment: `${app}-${env.TLDRAW_ENV}`,
transient_environment: env.TLDRAW_ENV === 'preview',
production_environment: env.TLDRAW_ENV === 'production',
required_contexts: [],
auto_merge: false,
task: 'deploy',
})
await client.rest.repos.createDeploymentStatus({
owner: 'tldraw',
repo: 'tldraw',
deployment_id: (deployment.data as any).id,
state: 'success',
environment_url: deploymentUrl,
log_url: inspectUrl,
})
}
/** Deploy a worker to wrangler, returning the deploy ID */
export async function wranglerDeploy({
location,
dryRun,
env,
vars,
sentry,
}: {
location: string
dryRun: boolean
env: string
vars: Record<string, string>
sentry?: {
authToken: string
project: string
environment?: string
}
}) {
const varsArray = []
for (const [key, value] of Object.entries(vars)) {
varsArray.push('--var', `${key}:${value}`)
}
const out = await exec(
'yarn',
[
'wrangler',
'deploy',
dryRun ? '--dry-run' : null,
'--env',
env,
'--outdir',
'.wrangler/dist',
...varsArray,
],
{
pwd: location,
env: {
NODE_ENV: 'production',
// wrangler needs CI=1 set to prevent it from trying to do interactive prompts
CI: '1',
},
}
)
if (dryRun) return
const versionMatch = out.match(/Current Version ID: (.+)/)
if (!versionMatch) {
throw new Error('Could not find the deploy ID in wrangler output')
}
const workerNameMatch = out.match(/Uploaded ([^ ]+)/)
if (!workerNameMatch) {
throw new Error('Could not find the worker name in wrangler output')
}
if (sentry) {
const release = `${sentry.environment ?? workerNameMatch[1]}.${versionMatch[1]}`
const sentryEnv = {
SENTRY_AUTH_TOKEN: sentry.authToken,
SENTRY_ORG: 'tldraw',
SENTRY_PROJECT: sentry.project,
}
// create a sentry release:
await exec('yarn', ['run', '-T', 'sentry-cli', 'releases', 'new', release], {
pwd: location,
env: sentryEnv,
})
// upload sourcemaps to the release:
await exec(
'yarn',
[
'run',
'-T',
'sentry-cli',
'releases',
'files',
release,
'upload-sourcemaps',
'.wrangler/dist',
],
{
pwd: location,
env: sentryEnv,
}
)
}
}
export interface ServiceBinding {
binding: string
service: string
}
export async function setWranglerPreviewConfig(
location: string,
{
name,
routeHostname,
serviceBinding,
}: { name: string; routeHostname?: string; serviceBinding?: ServiceBinding },
queueName?: string
) {
// A zone route instead of a custom domain: custom domains mint a dedicated
// certificate pack per hostname, while routes are covered by the zone's
// universal wildcard cert and the existing proxied *.tldraw.xyz DNS record.
const zoneName = routeHostname?.split('.').slice(-2).join('.')
const additionalProperties = `name = "${name}"
${routeHostname ? `routes = [ { pattern = "${routeHostname}/*", zone_name = "${zoneName}" } ]` : ''}
${serviceBinding ? `services = [ {binding = "${serviceBinding.binding}", service = "${serviceBinding.service}" } ]` : ''}`
const envPreviewSection = `\n[env.preview]\n${additionalProperties}\n`
const path = join(location, 'wrangler.toml')
let data = readFileSync(path).toString()
// Replace the entire [env.preview] section including all its properties
// Match from [env.preview] to the next section or end of file
const previewSectionRegex = /\n\[env\.preview\]\n(?:[^[](?:[^\n]*\n)*)?/
if (previewSectionRegex.test(data)) {
data = data.replace(previewSectionRegex, envPreviewSection)
} else {
data += envPreviewSection
}
if (queueName) {
const envPreviewQueuesSection = `\n[[env.preview.queues.producers]]
queue = "${queueName}"
binding = "QUEUE"
[[env.preview.queues.consumers]]
queue = "${queueName}"
max_retries =10
`
data += envPreviewQueuesSection
}
writeFileSync(path, data)
}

View File

@@ -0,0 +1,183 @@
import assert from 'assert'
import { writeFileSync } from 'fs'
import * as diff from 'diff'
import kleur from 'kleur'
import * as tar from 'tar'
import tmp from 'tmp'
import { exec } from './exec'
import { nicelog } from './nicelog'
import { PackageDetails, getAllPackageDetails } from './publishing'
async function getPackageFirstDiff(
pkg: PackageDetails,
publishedVersion: string
): Promise<Diff | null> {
assert(process.env.TLDRAW_BEMO_URL, 'TLDRAW_BEMO_URL env var must be set')
const dir = tmp.dirSync({ unsafeCleanup: true })
const dirPath = dir.name
try {
const unscopedName = pkg.name.replace('@tldraw/', '')
const url = `https://registry.npmjs.org/${pkg.name}/-/${unscopedName}-${publishedVersion}.tgz`
const res = await fetch(url)
if (res.status === 404) {
// The package is not published at this version. This happens when a
// new package was added to the monorepo since the last release, or
// when a previous publish was interrupted after the version-bump
// commit landed but before npm publish completed. In either case
// treat it as an added package so the release proceeds instead of
// failing the entire workflow.
nicelog(
`No published tarball for ${pkg.name}@${publishedVersion} (${url}); treating as added.`
)
return { type: 'added', packageName: pkg.name, filePath: pkg.name }
}
if (res.status >= 400) {
throw new Error(`Failed to fetch ${url}: ${res.status}`)
}
const publishedTarballPath = `${dirPath}/published-package.tgz`
writeFileSync(publishedTarballPath, Buffer.from(await res.arrayBuffer()))
const publishedManifest = getTarballManifestSync(publishedTarballPath)
const localTarballPath = `${dirPath}/local-package.tgz`
await exec('yarn', ['pack', '--out', localTarballPath], { pwd: pkg.dir, env: process.env })
const localManifest = getTarballManifestSync(localTarballPath)
return getManifestFirstDiff(pkg.name, publishedManifest, localManifest)
} finally {
dir.removeCallback()
}
}
type Diff =
| {
type: 'added' | 'removed'
packageName: string
filePath: string
}
| {
type: 'changed'
packageName: string
filePath: string
diff: string
}
// In the `tldraw` package these files are generated during publishing from the
// docs content in `apps/docs/content` (see `generate-tldraw-package-docs.ts`),
// not from SDK source. A docs-only change therefore alters them, which would
// otherwise make this diff check report a package change and cut a spurious SDK
// patch release. Ignore them for the `tldraw` package so that docs-only patches
// don't trigger a new SDK version. Other packages ship a hand-authored DOCS.md
// as real source, so we only skip these for `tldraw`.
//
// `generate-tldraw-package-docs.ts` writes these files to the package root, so
// in the tarball they live at exactly `package/<name>` (tarball entry paths are
// prefixed with `package/`). Match the full path rather than the basename so a
// same-named file in some other folder still counts as a real change.
const GENERATED_DOCS_FILES = new Set(['package/DOCS.md', 'package/RELEASE_NOTES.md'])
const GENERATED_DOCS_PACKAGE = 'tldraw'
function isGeneratedDocsFile(packageName: string, filePath: string) {
if (packageName !== GENERATED_DOCS_PACKAGE) return false
return GENERATED_DOCS_FILES.has(filePath)
}
function getManifestFirstDiff(
packageName: string,
a: Record<string, Buffer>,
b: Record<string, Buffer>
): Diff | null {
const aKeys = Object.keys(a).filter((key) => !isGeneratedDocsFile(packageName, key))
const bKeys = Object.keys(b).filter((key) => !isGeneratedDocsFile(packageName, key))
for (const key of aKeys) {
if (!bKeys.includes(key)) {
return { type: 'removed', packageName, filePath: key }
}
if (!a[key].equals(b[key])) {
return {
type: 'changed',
packageName,
filePath: key,
diff: diff.createTwoFilesPatch(key, key, a[key].toString(), b[key].toString()),
}
}
}
for (const key of bKeys) {
if (!aKeys.includes(key)) {
return { type: 'added', packageName, filePath: key }
}
}
return null
}
function getTarballManifestSync(tarballPath: string) {
const manifest: Record<string, Buffer> = {}
tar.list({
file: tarballPath,
onentry: (entry) => {
entry.on('data', (data) => {
// we could hash these to reduce memory but it's probably fine
const existing = manifest[entry.path]
if (existing) {
manifest[entry.path] = Buffer.concat([existing, data])
} else {
manifest[entry.path] = data
}
})
},
sync: true,
})
return manifest
}
export function formatDiff(diff: Diff) {
let message = kleur.cyan().bold(diff.packageName) + ' changed:'
switch (diff.type) {
case 'added':
message += `\n New file added ${kleur.green().bold(diff.filePath)}`
break
case 'removed':
message += `\n File removed ${kleur.red().bold(diff.filePath)}`
break
case 'changed':
message += `\n File changed ${kleur.yellow().bold(diff.filePath)}\n\n`
message += diff.diff
.split('\n')
.map((line) => {
if (line.startsWith('+')) {
return kleur.green(line)
} else if (line.startsWith('-')) {
return kleur.red(line)
} else {
return line
}
})
.join('\n')
break
}
return message
}
/**
* Compare each local package against the published tarball at
* `publishedVersion` on npm and return the first detected difference, or null
* if every package is identical to its published counterpart.
*
* Callers should pass the latest version that's actually published on npm
* (e.g. from `npm show <pkg> version`) rather than the version in the local
* `package.json`. The local version can drift ahead of npm if a previous
* publish was interrupted after the version-bump commit was pushed; using the
* known-published version keeps the diff check robust to that state.
*/
export async function getAnyPackageDiff(publishedVersion: string): Promise<Diff | null> {
const details = await getAllPackageDetails()
for (const pkg of Object.values(details)) {
const diff = await getPackageFirstDiff(pkg, publishedVersion)
if (diff) {
return diff
}
}
return null
}

View File

@@ -0,0 +1,124 @@
const MASK_PLACEHOLDER = '\x00MASKED\x00'
function sanitizeVariables(errorOutput: string, secretValues?: string[]): string {
let sanitized = errorOutput
// Value-based masking: replace any occurrence of known secret values (like GitHub Actions).
// Uses an internal placeholder so regex patterns below can't re-match masked output.
// Sort longest-first so a longer secret is masked before a shorter substring of it.
if (secretValues) {
const sorted = secretValues.filter((v) => v.length >= 8).sort((a, b) => b.length - a.length)
for (const value of sorted) {
sanitized = sanitized.replaceAll(value, MASK_PLACEHOLDER)
}
}
// Sanitize wrangler --var KEY:VALUE patterns
sanitized = sanitized.replace(/(--var\s+)(\w+):[^ \n]+/g, '$1$2:`***`')
// Sanitize KEY=VALUE patterns where KEY looks like an env var and value is long enough
// to be a secret (e.g. flyctl secrets set). Short values like =debug are left alone.
sanitized = sanitized.replace(/\b([A-Z][A-Z_0-9]{2,})=([^ \n]{8,})/g, '$1=`***`')
// Sanitize --token VALUE and --token=VALUE patterns (e.g. vercel --token xxx)
sanitized = sanitized.replace(/(--token[\s=])\S+/g, '$1`***`')
// Sanitize connection strings (postgres://, redis://, etc.)
sanitized = sanitized.replace(
/\b(postgres|postgresql|mysql|redis|mongodb|amqp):\/\/[^\s"']+/gi,
'$1://`***`'
)
// Replace internal placeholder with final mask
sanitized = sanitized.replaceAll(MASK_PLACEHOLDER, '`***`')
return sanitized
}
export class Discord {
static AT_TEAM_MENTION = '<@&1414904550917144586>'
constructor(opts: {
webhookUrl: string
shouldNotify: boolean
totalSteps?: number
messagePrefix?: string
secretValues?: string[]
}) {
this.webhookUrl = opts.webhookUrl
this.shouldNotify = opts.shouldNotify
this.totalSteps = opts.totalSteps ?? 0
this.messagePrefix = opts.messagePrefix ?? ''
this.secretValues = opts.secretValues
}
webhookUrl: string
shouldNotify: boolean
totalSteps: number
currentStep = 0
messagePrefix: string
private secretValues?: string[]
private async send(method: string, url: string, body: unknown): Promise<any> {
try {
const response = await fetch(`${this.webhookUrl}${url}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) {
console.warn(
`Discord webhook request failed: ${method} -> ${response.status} ${response.statusText}`
)
return null
}
return response.json()
} catch (err) {
const errString = err instanceof Error ? (err.stack ?? err.message) : String(err)
console.warn(
`Discord webhook request failed: ${method}\n${sanitizeVariables(errString, this.secretValues)}`
)
return null
}
}
async message(content: string, { always = false }: { always?: boolean } = {}) {
if (!always && !this.shouldNotify) {
return {
edit: () => {
// noop
},
}
}
const prefixedContent = this.messagePrefix ? `${this.messagePrefix} ${content}` : content
const message = await this.send('POST', '?wait=true', {
content: sanitizeVariables(prefixedContent, this.secretValues),
})
return {
edit: async (newContent: string) => {
if (!message?.id) return
const prefixedNewContent = this.messagePrefix
? `${this.messagePrefix} ${newContent}`
: newContent
await this.send('PATCH', `/messages/${message.id}`, {
content: sanitizeVariables(prefixedNewContent, this.secretValues),
})
},
}
}
async step<T>(content: string, cb: () => Promise<T>): Promise<T> {
this.currentStep++
const message = await this.message(`[${this.currentStep}/${this.totalSteps}] ${content}...`)
try {
const result = await cb()
await message.edit(`${content} ✅`)
return result
} catch (err) {
await message.edit(`${content} ❌`)
throw err
}
}
}

View File

@@ -0,0 +1,76 @@
import { execFile } from 'child_process'
import { nicelog } from './nicelog'
interface ExecOpts {
pwd?: string
processStdoutLine?(line: string): void
processStderrLine?(line: string): void
env?: Partial<NodeJS.ProcessEnv>
}
export function prefixOutput(prefix: string) {
return {
processStdoutLine: (line: string) => process.stdout.write(`${prefix}${line}\n`),
processStderrLine: (line: string) => process.stderr.write(`${prefix}${line}\n`),
}
}
export async function exec(
command: string,
args: (string | null)[],
{
pwd = process.cwd(),
processStdoutLine = (line) => process.stdout.write(`${line}\n`),
processStderrLine = (line) => process.stderr.write(`${line}\n`),
env,
}: ExecOpts = {}
): Promise<string> {
nicelog(`> $ ${command} ${args.join(' ')} (in ${pwd}))`)
return new Promise((resolve, reject) => {
const data: string[] = []
const childProcess = execFile(
command,
args.filter((arg): arg is string => !!arg),
{ cwd: pwd, env: { ...process.env, ...env } },
(err) => {
if (err) reject(err)
else resolve(data.join(''))
}
)
let pendingStdoutLine = ''
childProcess.stdout!.on('data', (chunk) => {
const chunkString: string = chunk.toString('utf-8')
data.push(chunkString)
const lines = chunkString.split('\n')
lines[0] = pendingStdoutLine + lines[0]
pendingStdoutLine = lines.pop() ?? ''
for (const line of lines) {
processStdoutLine(line)
}
})
childProcess.stdout!.on('close', () => {
processStdoutLine(pendingStdoutLine)
})
let pendingStderrLine = ''
childProcess.stderr!.on('data', (chunk) => {
const chunkString: string = chunk.toString('utf-8')
data.push(chunkString)
const lines = chunkString.split('\n')
lines[0] = pendingStderrLine + lines[0]
pendingStderrLine = lines.pop() ?? ''
for (const line of lines) {
processStderrLine(line)
}
})
childProcess.stderr!.on('close', () => {
processStderrLine(pendingStderrLine)
})
})
}

View File

@@ -0,0 +1,90 @@
import { existsSync, readFileSync } from 'fs'
import { readFile, writeFile as writeFileUnchecked } from 'fs/promises'
import { dirname, join, relative } from 'path'
import { fileURLToPath } from 'url'
import json5 from 'json5'
import { format } from 'oxfmt'
import { nicelog } from './nicelog'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
export const REPO_ROOT = join(__dirname, '../../..')
const _rootPackageJsonPath = join(REPO_ROOT, 'package.json')
if (!existsSync(_rootPackageJsonPath)) {
throw new Error('expected to find package.json in REPO_ROOT')
}
const _rootPackageJson = JSON.parse(readFileSync(_rootPackageJsonPath, 'utf8'))
if (_rootPackageJson['name'] !== '@tldraw/monorepo') {
throw new Error('expected to find @tldraw/monorepo in REPO_ROOT package.json')
}
export async function readJsonIfExists(file: string) {
const fileContents = await readFileIfExists(file)
if (fileContents === null) {
return null
}
return json5.parse(fileContents)
}
export async function readFileIfExists(file: string) {
try {
return await readFile(file, 'utf8')
} catch {
return null
}
}
const oxfmtConfig = json5.parse(readFileSync(join(REPO_ROOT, '.oxfmtrc.json'), 'utf8'))
export async function writeCodeFile(
generator: string | null,
language: 'typescript' | 'javascript',
filePath: string,
code: string
) {
const source = generator
? `
// This file is automatically generated by ${generator}.
// Do not edit manually. Or do, I'm a comment, not a cop.
${code}
`
: code
const ext = language === 'typescript' ? 'ts' : 'js'
const result = await format(`file.${ext}`, source, oxfmtConfig)
await writeStringFile(filePath, result.code)
}
export async function writeStringFile(filePath: string, contents: string) {
await writeFile(filePath, Buffer.from(contents, 'utf-8'))
}
export async function writeFile(filePath: string, contents: Buffer) {
if (process.env.CI && !process.env.ALLOW_REFRESH_ASSETS_CHANGES) {
let existingContents: Buffer | null = null
try {
existingContents = await readFile(filePath)
} catch {
// Ignore
}
if (existingContents && !existingContents.equals(contents)) {
nicelog(
`Asset file ${relative(
REPO_ROOT,
filePath
)} has changed. Please run this script again and commit the changes.`
)
nicelog('Contents before:')
nicelog(existingContents.toString('utf-8'))
nicelog('\nContents after:')
nicelog(contents.toString('utf-8'))
process.exit(1)
}
}
await writeFileUnchecked(filePath, contents, 'utf-8')
}
export async function writeJsonFile(filePath: string, contents: unknown) {
await writeStringFile(filePath, JSON.stringify(contents, null, '\t') + '\n')
}

View File

@@ -0,0 +1,40 @@
import { spawnSync } from 'child_process'
/**
* Collect every descendant PID of `pid` by walking the process tree with `pgrep -P`.
*
* Returned breadth-first (direct children first, deepest descendants last). Callers that SIGKILL
* the tree should reverse this so they kill deepest-first.
*/
export function descendantPids(pid: number): number[] {
const result = spawnSync('pgrep', ['-P', String(pid)], { encoding: 'utf8' })
const childPids = (result.stdout ?? '')
.split('\n')
.map((line) => Number(line.trim()))
.filter((n) => Number.isInteger(n) && n > 0)
return childPids.flatMap((childPid) => [childPid, ...descendantPids(childPid)])
}
/**
* SIGKILL `pid` and its entire descendant tree.
*
* Our dev orchestrators spawn children through wrapper layers (`yarn run -T tsx …`) and processes
* that fork their own grandchildren (vite → esbuild, wrangler → workerd, zero-cache → worker
* subprocesses). `child.kill()` only signals the direct child, and SIGKILL is uncatchable and does
* not cascade — so killing a wrapper or parent just orphans everything beneath it, leaving processes
* that keep holding the fixed dev ports. Walking by PID crosses those wrapper and process-group
* boundaries.
*
* Collect the PIDs up front so the tree does not reparent out from under us while we kill, then
* SIGKILL deepest-first. The root `pid` itself is left alone so the caller can finish its own
* shutdown and exit. Safe to call from signal and synchronous `exit` handlers.
*/
export function killProcessTree(pid: number) {
for (const target of descendantPids(pid).reverse()) {
try {
process.kill(target, 'SIGKILL')
} catch {
// already gone
}
}
}

View File

@@ -0,0 +1,44 @@
interface Label {
// this is what the label is 'called' on github
name: string
// this is how we describe the label in our pull request template
description: string
// this is the section title for the label in our changelogs
changelogTitle: string
}
const TYPE_LABELS = [
{ name: `bugfix`, description: `Bug fix`, changelogTitle: '🐛 Bug Fixes' },
{
name: `improvement`,
description: `Product improvement`,
changelogTitle: '💄 Product Improvements',
},
{
name: `feature`,
description: `New feature`,
changelogTitle: '🎉 New Features',
},
{
name: `api`,
description: `API change`,
changelogTitle: '🛠️ API Changes',
},
{
name: `other`,
description: `Changes that don't affect SDK users, e.g. internal or .com changes`,
changelogTitle: '🤷 Other',
},
] as const satisfies Label[]
export function getLabelNames() {
return [...TYPE_LABELS].map((label) => label.name)
}
function formatTemplateOption(label: Label) {
return `- [ ] \`${label.name}\``
}
export function formatLabelOptionsForPRTemplate() {
return TYPE_LABELS.map(formatTemplateOption).join('\n')
}

View File

@@ -0,0 +1,18 @@
export function makeEnv<const Keys extends readonly string[]>(
keys: Keys
): Record<Keys[number], string> {
const env = {} as Record<string, string>
const missingVars = []
for (const key of keys) {
const value = process.env[key]
if (value === undefined) {
missingVars.push(key)
continue
}
env[key] = value
}
if (missingVars.length > 0) {
throw new Error(`Missing environment variables: ${missingVars.join(', ')}`)
}
return env as Record<Keys[number], string>
}

View File

@@ -0,0 +1,3 @@
export function nicelog(...args: any[]) {
console.log(...args)
}

View File

@@ -0,0 +1,57 @@
import { nicelog } from './nicelog'
/**
* The PostHog project we send CI engineering metrics to. `phc_` keys are public, write-only
* ingest keys, so keeping this in the repo is fine — it's the same project the e2e performance
* reporter writes to. See https://eu.posthog.com/project/45972
*/
const CI_METRICS_API_KEY = 'phc_i8oKgMzgV38sn3GfjswW9mevQ3gFlo7bJXekZFeDN6'
const DEFAULT_API_HOST = 'https://analytics.tldraw.com/ingest'
export interface CiMetricEvent {
event: string
/**
* Groups a series together in PostHog. Use a stable string per metric (e.g.
* `dotcom-bundle-size`) rather than a per-run id, so a trend over time is one person's events
* instead of thousands of one-off people.
*/
distinctId: string
properties: Record<string, unknown>
}
/**
* Send events to the CI metrics PostHog project. Everything goes in a single request to the
* `/batch/` endpoint: a build reports a few dozen events, and sending them one at a time would
* mean a few dozen round trips at the tail of a deploy, where a failure partway through leaves a
* build with some of its events recorded and the rest missing.
*/
export async function captureCiMetricEvents(events: CiMetricEvent[]) {
if (events.length === 0) return
const apiHost = process.env.POSTHOG_API_HOST || DEFAULT_API_HOST
const timestamp = new Date().toISOString()
const response = await fetch(`${apiHost}/batch/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: CI_METRICS_API_KEY,
batch: events.map(({ event, distinctId, properties }) => ({
event,
properties: {
...properties,
distinct_id: distinctId,
$lib: 'tldraw-ci-metrics',
$lib_version: '1.0.0',
},
timestamp,
})),
}),
})
if (!response.ok) {
throw new Error(`PostHog API error: ${response.status} ${response.statusText}`)
}
nicelog(`Sent ${events.length} event(s) to PostHog`)
}

View File

@@ -0,0 +1,64 @@
import { Octokit } from 'octokit'
import { exec } from './exec'
import { nicelog } from './nicelog'
export async function getPrDetailsByNumber(octokit: Octokit, prNumber: number) {
return await octokit.rest.pulls
.get({
owner: 'tldraw',
repo: 'tldraw',
pull_number: prNumber,
})
.then((res) => res.data)
}
export async function getPrDetails(octokit: Octokit) {
const lastCommitMessage = (await exec('git', ['log', '-1', '--oneline'])).trim()
const lastPRNumber = lastCommitMessage.match(/\(#(\d+)\)$/)?.[1]
if (!lastPRNumber) {
nicelog('No PR number found in last commit message. Exiting...')
return null
}
return await getPrDetailsByNumber(octokit, parseInt(lastPRNumber))
}
export async function getPrDetailsAndCommitSha(octokit: Octokit) {
const prNumber = process.env.PR_NUMBER
nicelog(`PR_NUMBER environment variable: ${prNumber}`)
let pr: PullRequest | null
let commitSha: string | null = null
if (prNumber) {
// Post-merge mode: get PR details by number
nicelog(`Post-merge mode: getting PR details for #${prNumber}`)
pr = await getPrDetailsByNumber(octokit, parseInt(prNumber))
if (!pr) {
nicelog('Could not retrieve PR details. Exiting...')
return null
}
commitSha = pr.merge_commit_sha
} else {
// Merge-time mode: get PR details from commit message
nicelog('Merge-time mode: getting PR details from commit message')
pr = await getPrDetails(octokit)
if (!pr) {
nicelog('Could not retrieve PR details. Exiting...')
return null
}
commitSha = (await exec('git', ['rev-parse', 'HEAD'])).trim()
}
if (!commitSha) {
nicelog('PR is not merged or merge commit SHA not available. Exiting...')
return null
}
return { pr, commitSha }
}
export type PullRequest = NonNullable<Awaited<ReturnType<typeof getPrDetailsByNumber>>>
export function labelPresent(pr: PullRequest, labelName: string): boolean {
return pr.labels.some((label) => label.name === labelName)
}

View File

@@ -0,0 +1,295 @@
import { execSync } from 'child_process'
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
import path, { join } from 'path'
import { Octokit } from '@octokit/rest'
import { fetch } from 'cross-fetch'
import { glob } from 'glob'
import { parse } from 'semver'
import { exec } from './exec'
import { REPO_ROOT } from './file'
import { nicelog } from './nicelog'
import { getAllWorkspacePackages } from './workspace'
export interface PackageDetails {
name: string
dir: string
localDeps: string[]
version: string
}
async function getPackageDetails(dir: string): Promise<PackageDetails | null> {
const packageJsonPath = path.join(dir, 'package.json')
if (!existsSync(packageJsonPath)) {
return null
}
const packageJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'))
if (packageJson.private) {
return null
}
const workspacePackages = await getAllWorkspacePackages()
return {
name: packageJson.name,
dir,
version: packageJson.version,
localDeps: Object.keys(packageJson.dependencies ?? {}).filter((dep) =>
workspacePackages.some((p) => p.name === dep)
),
}
}
export async function getAllPackageDetails(): Promise<Record<string, PackageDetails>> {
const dirs = readdirSync(join(REPO_ROOT, 'packages'))
const details = await Promise.all(
dirs.map((dir) => getPackageDetails(path.join(REPO_ROOT, 'packages', dir)))
)
const results = details.filter((x): x is PackageDetails => Boolean(x))
return Object.fromEntries(results.map((result) => [result.name, result]))
}
export async function setAllVersions(version: string, options?: { stageChanges?: boolean }) {
const packages = await getAllPackageDetails()
for (const packageDetails of Object.values(packages)) {
const manifest = JSON.parse(readFileSync(path.join(packageDetails.dir, 'package.json'), 'utf8'))
manifest.version = version
writeFileSync(
path.join(packageDetails.dir, 'package.json'),
JSON.stringify(manifest, null, '\t') + '\n'
)
}
await exec('yarn', ['refresh-assets', '--force'], { env: { ALLOW_REFRESH_ASSETS_CHANGES: '1' } })
const lernaJson = JSON.parse(readFileSync('lerna.json', 'utf8'))
lernaJson.version = version
writeFileSync('lerna.json', JSON.stringify(lernaJson, null, '\t') + '\n')
execSync('yarn')
if (options?.stageChanges) {
await stageAllPackageJsonChanges()
}
}
async function stageAllPackageJsonChanges() {
// stage the changes
const packageJsonFilesToAdd = []
for (const workspace of await getAllWorkspacePackages()) {
if (workspace.relativePath.startsWith('packages/')) {
packageJsonFilesToAdd.push(`${workspace.relativePath}/package.json`)
}
}
const versionFilesToAdd = glob.sync('**/*/version.ts', {
ignore: ['node_modules/**'],
follow: false,
})
console.log('versionFilesToAdd', versionFilesToAdd)
await exec('git', [
'add',
'--update',
'lerna.json',
...packageJsonFilesToAdd,
...versionFilesToAdd,
])
}
function assertExists<T>(v: T | null | undefined): T {
if (v === null || v === undefined) throw new Error('Expected value to exist')
return v
}
export async function getLatestTldrawVersionFromNpm({
versionPrefix,
}: { versionPrefix?: string } = {}) {
if (!versionPrefix)
return assertExists(parse((await exec('npm', ['show', 'tldraw', 'version'])).trim()))
const versions = (await exec('npm', ['show', 'tldraw@~' + versionPrefix, 'version'])).trim()
if (versions.startsWith('tldraw')) {
return assertExists(parse(versions.split('\n').pop()?.split(' ')[1].replaceAll("'", '')))
}
return assertExists(parse(versions))
}
function topologicalSortPackages(packages: Record<string, PackageDetails>) {
const sorted: PackageDetails[] = []
const visited = new Set<string>()
function visit(packageName: string, path: string[]) {
if (visited.has(packageName)) {
return
}
visited.add(packageName)
const packageDetails = packages[packageName]
if (!packageDetails) {
throw new Error(`Could not find package ${packageName}. path: ${path.join(' -> ')}`)
}
packageDetails.localDeps.forEach((dep) => visit(dep, [...path, dep]))
sorted.push(packageDetails)
}
Object.keys(packages).forEach((packageName) => visit(packageName, [packageName]))
return sorted
}
export async function publish(distTag?: string) {
// Authentication uses npm's trusted publisher OIDC flow. The publish job in
// CI must grant `permissions: id-token: write` so yarn (>= 4.10, which we are
// on via `packageManager`) can exchange the GitHub-issued OIDC token for a
// short-lived publish token automatically.
//
// We invoke `yarn npm publish` rather than `npm publish` directly so that
// yarn rewrites `workspace:*` dependency specifiers in the published
// tarball into the concrete sibling versions. `npm publish` has no concept
// of yarn's workspace protocol and would ship `"workspace:*"` literally,
// breaking installs for any consumer outside this monorepo.
// See https://docs.npmjs.com/trusted-publishers
const packages = await getAllPackageDetails()
const publishOrder = topologicalSortPackages(packages)
// npm can take several minutes before a published version is readable. Waiting only
// on a package's own dependencies keeps a newly visible package installable, without
// making the run wait for every package's delay in turn.
const readable = new Map<string, Promise<void>>()
for (const packageDetails of publishOrder) {
await Promise.all(packageDetails.localDeps.map((dep) => readable.get(dep)))
const tag = distTag ?? parse(packageDetails.version)?.prerelease[0] ?? 'latest'
nicelog(
`Publishing ${packageDetails.name} with version ${packageDetails.version} under tag @${tag}`
)
await retry(
async () => {
let output = ''
const publishStart = Date.now()
nicelog(
`[publish] ${packageDetails.name}@${packageDetails.version} starting npm publish...`
)
try {
await exec(
`yarn`,
[
'npm',
'publish',
'--tag',
String(tag),
'--tolerate-republish',
'--provenance',
'--access',
'public',
],
{
pwd: packageDetails.dir,
processStdoutLine: (line) => {
output += line + '\n'
nicelog(line)
},
processStderrLine: (line) => {
output += line + '\n'
nicelog(line)
},
}
)
} catch (e) {
// A retry after a publish that actually landed is rejected as "published",
// or as "staged" (409) while npm is still processing the first attempt.
const lowerOutput = output.toLowerCase()
if (
lowerOutput.includes('cannot publish over the previously published versions') ||
lowerOutput.includes('cannot publish over previously staged version')
) {
nicelog(
`[publish] ${packageDetails.name}@${packageDetails.version} already published or staged, skipping`
)
return
}
throw e
}
const elapsed = ((Date.now() - publishStart) / 1000).toFixed(1)
nicelog(
`[publish] ${packageDetails.name}@${packageDetails.version} npm publish done (${elapsed}s)`
)
},
{
delay: 10_000,
numAttempts: 5,
}
)
const whenReadable = waitUntilReadable(packageDetails)
// Rejections surface when awaited below; without this, one arriving mid-loop would
// kill the process in the middle of another package's publish.
whenReadable.catch(() => {})
readable.set(packageDetails.name, whenReadable)
}
await Promise.all(readable.values())
}
function waitUntilReadable(packageDetails: PackageDetails) {
const url = `https://registry.npmjs.org/${packageDetails.name}/${packageDetails.version}`
return retry(
async ({ attempt, total }) => {
const res = await fetch(url, { method: 'HEAD' })
if (res.status >= 400) {
nicelog(
`[verify] ${packageDetails.name}@${packageDetails.version} not readable yet (${res.status}), attempt ${attempt + 1} of ${total}`
)
throw new Error(`Package not found: ${url} (${res.status})`)
}
nicelog(`[verify] ${packageDetails.name}@${packageDetails.version} is readable`)
},
{
delay: 10_000,
// 15 minutes; the slowest package took over 8 minutes in September 2026.
numAttempts: 90,
}
)
}
function retry(
fn: (args: { attempt: number; remaining: number; total: number }) => Promise<void>,
opts: {
numAttempts: number
delay: number
}
): Promise<void> {
return new Promise((resolve, reject) => {
let attempts = 0
function attempt() {
fn({ attempt: attempts, remaining: opts.numAttempts - attempts, total: opts.numAttempts })
.then(resolve)
.catch((err) => {
attempts++
if (attempts >= opts.numAttempts) {
reject(err)
} else {
setTimeout(attempt, opts.delay)
}
})
}
attempt()
})
}
export async function publishProductionDocsAndExamplesAndBemo({
gitRef = 'HEAD',
}: { gitRef?: string } = {}) {
await exec('git', ['push', 'origin', `${gitRef}:docs-production`, `--force`])
await exec('git', ['push', 'origin', `${gitRef}:bemo-production`, `--force`])
}
export async function triggerBumpVersionsWorkflow(ghToken: string) {
const octokit = new Octokit({ auth: ghToken })
await octokit.rest.actions.createWorkflowDispatch({
owner: 'tldraw',
repo: 'tldraw',
workflow_id: 'bump-versions.yml',
ref: 'main',
})
}

View File

@@ -0,0 +1,19 @@
// GitHub skips both push- and pull_request-triggered workflows when one of these markers appears
// in a commit message, in the positions documented at
// https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs
// `[^\S\r\n]` rather than `\s`: eating the newlines around a marker on its own body line would
// close the blank line separating subject from body, and `git commit --amend -m` would then fold
// the whole first paragraph into the subject.
const SKIP_CI_MARKER = /[^\S\r\n]*\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]/gi
/**
* Remove GitHub's skip-ci markers from a commit message or PR title.
*
* Release-notes PRs carry `[skip ci]` in their title on purpose, so their squash-merge onto `main`
* doesn't run the push workflows. The hotfix scripts cherry-pick that same commit onto a branch
* whose push *must* trigger a workflow (the release branch's publish, or the hotfixes branch's
* production build), so the marker has to go before the push.
*/
export function stripSkipCiMarkers(message: string): string {
return message.replace(SKIP_CI_MARKER, '').trim()
}

View File

@@ -0,0 +1,42 @@
import { readFileSync, writeFileSync } from 'fs'
import path from 'path'
import { namedTypes } from 'ast-types'
import { glob } from 'glob'
import { parse, print, visit } from 'recast'
export function sortUnions(tsbuildDir: string) {
for (const file of glob.sync(path.join(tsbuildDir, '**/*.d.ts'))) {
try {
const code = parse(readFileSync(file, 'utf8'), {
parser: require('recast/parsers/typescript'),
})
visit(code, {
visitTSUnionType(path) {
this.traverse(path)
const val = path.value as namedTypes.TSUnionType
val.types = val.types.sort((a, b) => {
const aText = print(a).code
const bText = print(b).code
return aText.localeCompare(bText)
})
return false
},
visitTSTypeLiteral(path) {
this.traverse(path)
const val = path.value as namedTypes.TSTypeLiteral
val.members = val.members.sort((a, b) => {
const aText = print(a).code
const bText = print(b).code
return aText.localeCompare(bText)
})
return false
},
})
writeFileSync(file, print(code).code)
} catch (e) {
throw new Error(`Error sorting unions in ${file}`, { cause: e })
}
}
}

View File

@@ -0,0 +1,80 @@
import { T } from '@tldraw/validate'
export const EXPORT_CONFIG_KEY = 'tldraw_template' as const
export const TemplateDeployConfig = T.object({
// Where the live demo is hosted. The vercel-hosted demos deploy through vercel's own git
// integration, so only cloudflare templates carry a `deploy` config.
target: T.literalEnum('cloudflare'),
// The hostname the demo is served from. Must match the custom domain route in wrangler.toml.
host: T.string,
})
export type TemplateDeployConfig = T.TypeOf<typeof TemplateDeployConfig>
export const TemplateConfig = T.object({
repo: T.string,
cli: T.object({
name: T.string,
description: T.string,
shortDescription: T.string.optional(),
order: T.number.optional(),
}).optional(),
deploy: TemplateDeployConfig.optional(),
scripts: T.dict(T.string, T.nullable(T.string)).optional(),
})
export type TemplateConfig = T.TypeOf<typeof TemplateConfig>
export const PRODUCT_CONFIG_KEY = 'tldraw_product' as const
/**
* Commercial product metadata for a published package. The `stableId` identifies the commercial
* component a package belongs to; it is referenced by legal in order forms and must never change,
* even if the package is renamed or moved. Several packages can share one stable id. See
* `internal/docs/product-stable-ids.md` for the full convention.
*/
export const ProductConfig = T.object({
// Immutable commercial identifier, e.g. `tldraw:sdk-core`. Never changes on rename/refactor.
stableId: T.string,
// Customer-facing label, matching the wording used on the order form, e.g. 'Collaboration
// module'. Unlike stableId this may be reworded; the id is what contracts reference.
name: T.string,
// `product` is a top-level order form line item; `feature` is an optional part of its parent
// that can be licensed on its own, listed indented beneath it on the order form.
type: T.literalEnum('product', 'feature'),
// The component this one belongs to, e.g. commenting's parent is the collaboration module.
// Components without a parent are top-level order form line items.
parent: T.string.optional(),
// Whether this component is separately licensed rather than part of the standard SDK offering.
premium: T.boolean,
// For premium components: the license key FLAGS bit (in packages/editor's LicenseManager) that
// entitles a customer to this component.
licenseFlag: T.string.optional(),
})
export type ProductConfig = T.TypeOf<typeof ProductConfig>
export const PackageJson = T.object({
name: T.string,
private: T.boolean.optional(),
workspaces: T.arrayOf(T.string).optional(),
[EXPORT_CONFIG_KEY]: TemplateConfig.optional(),
[PRODUCT_CONFIG_KEY]: ProductConfig.optional(),
license: T.string.optional(),
description: T.string.optional(),
scripts: T.dict(T.string, T.nullable(T.string)).optional(),
dependencies: T.dict(T.string, T.string).optional(),
devDependencies: T.dict(T.string, T.string).optional(),
peerDependencies: T.dict(T.string, T.string).optional(),
optionalDependencies: T.dict(T.string, T.string).optional(),
engines: T.dict(T.string, T.string).optional(),
packageManager: T.string.optional(),
}).allowUnknownProperties()
export type PackageJson = T.TypeOf<typeof PackageJson>
export const TsConfigJson = T.object({
references: T.arrayOf(
T.object({
path: T.string,
})
).optional(),
}).allowUnknownProperties()
export type TsConfigJson = T.TypeOf<typeof TsConfigJson>

View File

@@ -0,0 +1,98 @@
import fs from 'fs'
import path from 'path'
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import mime from 'mime'
import { exec } from './exec'
import { makeEnv } from './makeEnv'
const env = makeEnv(['R2_ACCESS_KEY_ID', 'R2_ACCESS_KEY_SECRET'])
const R2_URL = 'https://c34edc4e76350954b63adebde86d5eb1.r2.cloudflarestorage.com'
const R2_BUCKET = 'cdn'
const ASSETS_FOLDER = './packages/assets'
// How many files to upload to the CDN at once.
const UPLOAD_CONCURRENCY = 16
const R2 = new S3Client({
region: 'auto',
endpoint: R2_URL,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_ACCESS_KEY_SECRET,
},
})
async function uploadFile(key: string, fullPath: string) {
const fileStream = fs.createReadStream(fullPath)
const contentType = mime.getType(fullPath) ?? 'application/octet-stream'
const uploadParams = {
Bucket: R2_BUCKET,
Key: key,
Body: fileStream,
ContentType: contentType,
// these assets will never change, so we can cache them forever:
CacheControl: 'public, max-age=31536000, immutable',
}
await R2.send(new PutObjectCommand(uploadParams))
process.stdout.write(` • ${key} ✔️\n`)
}
function collectFiles(
prefix: string,
directoryPath: string
): Array<{ key: string; fullPath: string }> {
const files: Array<{ key: string; fullPath: string }> = []
for (const entry of fs.readdirSync(directoryPath, { withFileTypes: true })) {
const fullPath = path.join(directoryPath, entry.name)
const key = path.join(prefix, entry.name)
if (entry.isDirectory()) {
files.push(...collectFiles(key, fullPath))
} else if (entry.isFile()) {
files.push({ key, fullPath })
}
}
return files
}
export async function uploadStaticAssets(version: string) {
try {
await exec('yarn', ['refresh-assets'], { env: { ALLOW_REFRESH_ASSETS_CHANGES: '1' } })
const entries = fs.readdirSync(ASSETS_FOLDER, { withFileTypes: true })
// Gather every file across all asset folders up front so we can upload
// them with bounded concurrency instead of one at a time.
const files: Array<{ key: string; fullPath: string }> = []
for (const entry of entries) {
if (entry.name.startsWith('.')) continue
if (!entry.isDirectory()) continue
const folderName = entry.name
files.push(...collectFiles(`${version}/${folderName}`, `${ASSETS_FOLDER}/${folderName}`))
}
console.log(`Uploading ${files.length} static assets to CDN...`)
// Upload files in parallel, keeping at most UPLOAD_CONCURRENCY requests in
// flight at a time. Workers pull from a shared queue until it's empty.
let next = 0
async function worker() {
while (next < files.length) {
const { key, fullPath } = files[next++]
await uploadFile(key, fullPath)
}
}
await Promise.all(
Array.from({ length: Math.min(UPLOAD_CONCURRENCY, files.length) }, () => worker())
)
console.log('Uploaded!')
} catch (e) {
console.error(e)
process.exit(1)
}
}

Some files were not shown because too many files have changed in this diff Show More