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

View File

@@ -0,0 +1,12 @@
VITE_PUBLIC_SERVER="http://localhost:4848"
ZERO_UPSTREAM_DB="postgresql://user:password@127.0.0.1:6543/postgres"
ZERO_CVR_DB="postgresql://user:password@127.0.0.1:6543/postgres"
ZERO_CHANGE_DB="postgresql://user:password@127.0.0.1:6543/postgres"
ZERO_REPLICA_FILE="/tmp/zstart_replica.db"
ZERO_APP_PUBLICATIONS="zero_data"
ZERO_MUTATE_URL="http://localhost:8787/app/zero/mutate"
ZERO_QUERY_URL="http://localhost:8787/app/zero/query"
ZERO_ENABLE_CRUD_MUTATIONS=false
ZERO_LOG_LEVEL="info"
ZERO_ENABLE_STARTUP_MESSAGE=0
DO_NOT_TRACK=1

12
apps/dotcom/zero-cache/.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
!.env
# sst
.sst
# generated fly configs (contain secrets)
flyio-replication-manager.toml
flyio-view-syncer.toml
flyio.toml
# generated Dockerfile (from template)
Dockerfile

View File

@@ -0,0 +1,14 @@
FROM rocicorp/zero:__ZERO_VERSION
# Install nginx, supervisor, and envsubst (gettext)
RUN apk add --no-cache nginx supervisor gettext
# Copy our configs
COPY nginx.conf.template /etc/nginx/nginx.conf.template
COPY supervisord.conf /etc/supervisord.conf
COPY start.sh /start.sh
RUN chmod +x /start.sh
EXPOSE 4848
CMD ["/start.sh"]

View File

@@ -0,0 +1,256 @@
import { readFileSync, readdirSync } from 'fs'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import pg from 'pg'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
// Focused integration test for the `delete_file_states` trigger that cleans up
// guest file_state rows and guest home-group file links (group_file rows) when a
// file is unshared (shared: true -> false).
//
// Visiting a shared file links it into the visitor's home group via a group_file
// row (home group id == user id) — that link is what puts the file in their recent
// files, so unshare has to remove it as well as the file_state, or the file name
// lingers in an ex-guest's recent files after access is revoked.
//
// This talks to a real postgres (the trigger is plpgsql, so fakes can't exercise
// it). It is opt-in: set ZERO_CACHE_TEST_POSTGRES_URL (local dev stack:
// postgres://user:password@localhost:6543/postgres) to run it. Without a
// connection string the suite is skipped so CI stays green.
//
// Safety: the suite isolates itself in a throwaway schema, and it must stay safe
// even if the URL points at a shared database through a transaction-mode pooler
// (pgbouncer on 6432, Neon pooler). Transaction pooling can hand each statement
// to a different backend session, so session-level `SET search_path` cannot be
// trusted to stick — and unqualified DDL like `DROP TABLE ... CASCADE` would then
// run against `public`. So no statement here relies on session state: everything
// the test owns is schema-qualified explicitly, and the statements that cannot be
// qualified (the shipped function definition, executed verbatim, and the UPDATEs whose
// trigger body resolves table names at execution time) run inside a transaction
// with `SET LOCAL search_path` — a transaction is exactly the unit a
// transaction-mode pooler pins to a single backend, and SET LOCAL expires with it.
const DIRNAME = dirname(fileURLToPath(import.meta.url))
const CONNECTION_STRING = process.env.ZERO_CACHE_TEST_POSTGRES_URL
// The real, shipped function body, sliced out of whichever migration last defined it so the test
// exercises production's function rather than a hand-copied duplicate. Found by scanning rather
// than by filename: a hard-coded 034 kept this suite green against a body production no longer
// ran once 050 redefined the function.
function loadShippedFunction(name: string): string {
const dir = join(DIRNAME, 'migrations')
const header = new RegExp(`CREATE OR REPLACE FUNCTION (public\\.)?${name}\\(\\)`)
const migrationFile = readdirSync(dir)
.filter((f) => f.endsWith('.sql'))
.sort()
.filter((f) => header.test(readFileSync(join(dir, f), 'utf8')))
.at(-1)
if (!migrationFile) {
throw new Error(`no migration defines ${name}()`)
}
const migration = readFileSync(join(dir, migrationFile), 'utf8')
const start = migration.search(header)
// The body is dollar-quoted with whatever tag the migration chose ($$ early on, $function$
// in 050), and the statement ends at the first semicolon after the closing tag.
const tag = /AS\s+(\$[A-Za-z_]*\$)/.exec(migration.slice(start))
if (!tag) {
throw new Error(`could not find the body of ${name}() in ${migrationFile}`)
}
const close = migration.indexOf(tag[1], start + tag.index + tag[0].length)
const end = close === -1 ? -1 : migration.indexOf(';', close + tag[1].length)
if (end === -1) {
throw new Error(`could not find the end of ${name}() in ${migrationFile}`)
}
// Drop the `public.` qualifier: the migration targets public, while everything here lives
// in a throwaway schema pinned with SET LOCAL search_path. Left in place it would
// CREATE OR REPLACE the *production* function whenever this test runs against a shared
// database — the one way this suite could reach outside its own schema.
const sql = migration
.slice(start, end + 1)
.replace(header, `CREATE OR REPLACE FUNCTION ${name}()`)
if (sql.includes('public.')) {
throw new Error(`refusing to run ${name}(): a public. reference survived unqualifying`)
}
return sql
}
const DELETE_FILE_STATES_SQL = loadShippedFunction('delete_file_states')
const schemaName = `tldraw_test_${process.pid}`
// Minimal schema covering only the tables the trigger reads or writes, plus the
// trigger binding. Recreated before each test (the trigger references the function,
// which we (re)create first). Dropping `file` CASCADE also drops the trigger.
// Fully qualified so it cannot touch `public` no matter what session it runs on.
const SCHEMA_SQL = `
DROP TABLE IF EXISTS "${schemaName}"."file_state", "${schemaName}"."group_file", "${schemaName}"."file", "${schemaName}"."group_user", "${schemaName}"."group" CASCADE;
CREATE TABLE "${schemaName}"."group" (
"id" TEXT PRIMARY KEY
);
CREATE TABLE "${schemaName}"."group_user" (
"userId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
PRIMARY KEY ("userId", "groupId")
);
CREATE TABLE "${schemaName}"."file" (
"id" TEXT PRIMARY KEY,
-- NOT NULL mirrors production: since 050 every file belongs to a workspace
"owningGroupId" TEXT NOT NULL,
"shared" BOOLEAN NOT NULL
);
CREATE TABLE "${schemaName}"."file_state" (
"userId" TEXT NOT NULL,
"fileId" TEXT NOT NULL,
PRIMARY KEY ("userId", "fileId")
);
CREATE TABLE "${schemaName}"."group_file" (
"fileId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
PRIMARY KEY ("fileId", "groupId")
);
CREATE TRIGGER file_shared_update
AFTER UPDATE OF shared ON "${schemaName}"."file"
FOR EACH ROW
EXECUTE FUNCTION "${schemaName}".delete_file_states();
`
const describeMaybe = CONNECTION_STRING ? describe : describe.skip
describeMaybe('delete_file_states trigger (unshare cleanup)', () => {
// A single Client, not a Pool. Two reasons: a Pool reaps idle clients (10s
// default) and transparently replaces them, silently resetting session state;
// and inTestSchema's BEGIN/COMMIT must run on one client, while pool.query may
// use a different client per call.
let client: pg.Client
// Runs statements inside one transaction with search_path pinned to the test
// schema. This is for SQL we execute verbatim (the shipped function definition)
// and for statements whose trigger body resolves unqualified table names at
// execution time. SET LOCAL scopes the setting to the transaction, so it works
// through transaction-mode poolers and cannot leak to or from other sessions.
async function inTestSchema(...statements: string[]) {
await client.query('BEGIN')
try {
await client.query(`SET LOCAL search_path TO "${schemaName}"`)
for (const sql of statements) {
await client.query(sql)
}
await client.query('COMMIT')
} catch (err) {
await client.query('ROLLBACK')
throw err
}
}
beforeAll(async () => {
client = new pg.Client({ connectionString: CONNECTION_STRING })
await client.connect()
await client.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`)
await client.query(`CREATE SCHEMA "${schemaName}"`)
// sanity-check that SET LOCAL pins the schema for a whole transaction on
// this connection before any unqualified SQL runs
await client.query('BEGIN')
await client.query(`SET LOCAL search_path TO "${schemaName}"`)
const res = await client.query<{ schema: string }>('SELECT current_schema() AS schema')
await client.query('COMMIT')
if (res.rows[0]?.schema !== schemaName) {
throw new Error(
`SET LOCAL search_path did not hold within a transaction (current_schema() is ` +
`"${res.rows[0]?.schema}", expected "${schemaName}"). Refusing to run unqualified ` +
`DDL against this connection.`
)
}
})
afterAll(async () => {
if (!client) return
await client.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`)
await client.end()
})
async function seed() {
// group g1 with two members; guest is NOT a member
await client.query(`INSERT INTO "${schemaName}"."group" ("id") VALUES ('g1')`)
await client.query(
`INSERT INTO "${schemaName}"."group_user" ("userId", "groupId") VALUES ('uOwner', 'g1'), ('uMember', 'g1')`
)
await client.query(
`INSERT INTO "${schemaName}"."file" ("id", "owningGroupId", "shared") VALUES ('fGroup', 'g1', true)`
)
// a shared file we will NOT unshare, as a control
await client.query(
`INSERT INTO "${schemaName}"."file" ("id", "owningGroupId", "shared") VALUES ('fControl', 'g1', true)`
)
// everyone has a file_state on every file
for (const fileId of ['fGroup', 'fControl']) {
await client.query(
`INSERT INTO "${schemaName}"."file_state" ("userId", "fileId") VALUES ('uOwner', $1), ('uMember', $1), ('uGuest', $1)`,
[fileId]
)
}
// group_file rows: the owning group's own row for fGroup, plus home-group
// file links (home group id == user id) created by visiting a shared file
await client.query(
`INSERT INTO "${schemaName}"."group_file" ("fileId", "groupId") VALUES
('fGroup', 'g1'),
('fGroup', 'uMember'),
('fGroup', 'uGuest'),
('fControl', 'g1'),
('fControl', 'uGuest')`
)
}
// Unsharing fires the trigger, whose body reads `file_state` and `group_user`
// unqualified — resolved via search_path at execution time — so the UPDATE
// must run with the test schema pinned.
async function unshare(fileId: string) {
await inTestSchema(
`UPDATE "${schemaName}"."file" SET "shared" = false WHERE "id" = '${fileId}'`
)
}
async function statesFor(fileId: string): Promise<string[]> {
const res = await client.query<{ userId: string }>(
`SELECT "userId" FROM "${schemaName}"."file_state" WHERE "fileId" = $1 ORDER BY "userId"`,
[fileId]
)
return res.rows.map((r) => r.userId)
}
async function linksFor(fileId: string): Promise<string[]> {
const res = await client.query<{ groupId: string }>(
`SELECT "groupId" FROM "${schemaName}"."group_file" WHERE "fileId" = $1 ORDER BY "groupId"`,
[fileId]
)
return res.rows.map((r) => r.groupId)
}
beforeEach(async () => {
await inTestSchema(DELETE_FILE_STATES_SQL)
await client.query(SCHEMA_SQL)
await seed()
})
it('removes the guest state but keeps group members when a group-owned file is unshared', async () => {
await unshare('fGroup')
// owner + member of the owning group keep access; guest is cleaned up
expect(await statesFor('fGroup')).toEqual(['uMember', 'uOwner'])
})
it('removes the guest file link but keeps the owning group row and member links', async () => {
await unshare('fGroup')
// the file still lives in its owning group, and the member keeps their
// home-group link (they retain access); the guest's link is cleaned up
// so the file stops showing in their recent files
expect(await linksFor('fGroup')).toEqual(['g1', 'uMember'])
})
it('leaves still-shared files untouched', async () => {
await unshare('fGroup')
expect(await statesFor('fControl')).toEqual(['uGuest', 'uMember', 'uOwner'])
expect(await linksFor('fControl')).toEqual(['g1', 'uGuest'])
})
})

View File

@@ -0,0 +1,53 @@
# Pin the Compose project name so `docker compose up` (run by process-compose), its shutdown hook,
# and `yarn dev-app:clean` all act on the same project, containers, and volumes.
name: tldraw_dotcom_dev
services:
zstart_postgres:
image: simonfuhrer/postgresql:16.1-wal2json
shm_size: 1g
user: postgres
restart: always
healthcheck:
test: 'pg_isready -U user --dbname=postgres'
interval: 10s
timeout: 5s
retries: 5
ports:
- 6543:5432
environment:
POSTGRES_USER: user
POSTGRES_DB: postgres
POSTGRES_PASSWORD: password
command: |
postgres
-c wal_level=logical
-c max_wal_senders=10
-c max_replication_slots=5
-c max_connections=300
-c hot_standby=on
-c hot_standby_feedback=on
# Uncomment the following line to enable query logging
# -c config_file=/etc/postgresql/postgresql.conf
volumes:
- tlapp_pgdata:/var/lib/postgresql/data
- ./:/docker-entrypoint-initdb.d
- ./postgres-logging.conf:/etc/postgresql/postgresql.conf
pgbouncer:
image: edoburu/pgbouncer:latest
restart: always
ports:
- "6432:6432"
environment:
DATABASE_URL: postgres://user:password@zstart_postgres:5432/postgres
volumes:
- ./pgbouncer.ini:/etc/pgbouncer/pgbouncer.ini
- ./userlist.txt:/etc/pgbouncer/userlist.txt
# Don't show pg bouncer logs
command: >
sh -c "pgbouncer /etc/pgbouncer/pgbouncer.ini > /dev/null 2>&1"
volumes:
tlapp_pgdata:
driver: local

View File

@@ -0,0 +1,21 @@
[databases]
postgres = host=zstart_postgres port=5432 dbname=postgres user=user password=password
; neon config, doesn't seem to work due to pool_mode transaction
; https://neon.tech/docs/connect/connection-pooling#neon-pgbouncer-configuration-settings
; [pgbouncer]
; pool_mode=transaction
; max_client_conn=10000
; default_pool_size=64
; max_prepared_statements=0
; query_wait_timeout=120
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = plain
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 450
default_pool_size = 100
max_prepared_statements=10

View File

@@ -0,0 +1,844 @@
# -----------------------------
# PostgreSQL configuration file
# -----------------------------
#
# This file consists of lines of the form:
#
# name = value
#
# (The "=" is optional.) Whitespace may be used. Comments are introduced with
# "#" anywhere on a line. The complete list of parameter names and allowed
# values can be found in the PostgreSQL documentation.
#
# The commented-out settings shown in this file represent the default values.
# Re-commenting a setting is NOT sufficient to revert it to the default value;
# you need to reload the server.
#
# This file is read on server startup and when the server receives a SIGHUP
# signal. If you edit the file on a running system, you have to SIGHUP the
# server for the changes to take effect, run "pg_ctl reload", or execute
# "SELECT pg_reload_conf()". Some parameters, which are marked below,
# require a server shutdown and restart to take effect.
#
# Any parameter can also be given as a command-line option to the server, e.g.,
# "postgres -c log_connections=on". Some parameters can be changed at run time
# with the "SET" SQL command.
#
# Memory units: B = bytes Time units: us = microseconds
# kB = kilobytes ms = milliseconds
# MB = megabytes s = seconds
# GB = gigabytes min = minutes
# TB = terabytes h = hours
# d = days
#------------------------------------------------------------------------------
# FILE LOCATIONS
#------------------------------------------------------------------------------
# The default values of these variables are driven from the -D command-line
# option or PGDATA environment variable, represented here as ConfigDir.
#data_directory = 'ConfigDir' # use data in another directory
# (change requires restart)
#hba_file = 'ConfigDir/pg_hba.conf' # host-based authentication file
# (change requires restart)
#ident_file = 'ConfigDir/pg_ident.conf' # ident configuration file
# (change requires restart)
# If external_pid_file is not explicitly set, no extra PID file is written.
#external_pid_file = '' # write an extra PID file
# (change requires restart)
#------------------------------------------------------------------------------
# CONNECTIONS AND AUTHENTICATION
#------------------------------------------------------------------------------
# - Connection Settings -
listen_addresses = '*'
# comma-separated list of addresses;
# defaults to 'localhost'; use '*' for all
# (change requires restart)
#port = 5432 # (change requires restart)
#max_connections = 100 # (change requires restart)
#reserved_connections = 0 # (change requires restart)
#superuser_reserved_connections = 3 # (change requires restart)
#unix_socket_directories = '/tmp' # comma-separated list of directories
# (change requires restart)
#unix_socket_group = '' # (change requires restart)
#unix_socket_permissions = 0777 # begin with 0 to use octal notation
# (change requires restart)
#bonjour = off # advertise server via Bonjour
# (change requires restart)
#bonjour_name = '' # defaults to the computer name
# (change requires restart)
# - TCP settings -
# see "man tcp" for details
#tcp_keepalives_idle = 0 # TCP_KEEPIDLE, in seconds;
# 0 selects the system default
#tcp_keepalives_interval = 0 # TCP_KEEPINTVL, in seconds;
# 0 selects the system default
#tcp_keepalives_count = 0 # TCP_KEEPCNT;
# 0 selects the system default
#tcp_user_timeout = 0 # TCP_USER_TIMEOUT, in milliseconds;
# 0 selects the system default
#client_connection_check_interval = 0 # time between checks for client
# disconnection while running queries;
# 0 for never
# - Authentication -
#authentication_timeout = 1min # 1s-600s
#password_encryption = scram-sha-256 # scram-sha-256 or md5
#scram_iterations = 4096
# GSSAPI using Kerberos
#krb_server_keyfile = 'FILE:${sysconfdir}/krb5.keytab'
#krb_caseins_users = off
#gss_accept_delegation = off
# - SSL -
#ssl = off
#ssl_ca_file = ''
#ssl_cert_file = 'server.crt'
#ssl_crl_file = ''
#ssl_crl_dir = ''
#ssl_key_file = 'server.key'
#ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL' # allowed SSL ciphers
#ssl_prefer_server_ciphers = on
#ssl_ecdh_curve = 'prime256v1'
#ssl_min_protocol_version = 'TLSv1.2'
#ssl_max_protocol_version = ''
#ssl_dh_params_file = ''
#ssl_passphrase_command = ''
#ssl_passphrase_command_supports_reload = off
#------------------------------------------------------------------------------
# RESOURCE USAGE (except WAL)
#------------------------------------------------------------------------------
# - Memory -
#shared_buffers = 128MB # min 128kB
# (change requires restart)
#huge_pages = try # on, off, or try
# (change requires restart)
#huge_page_size = 0 # zero for system default
# (change requires restart)
#temp_buffers = 8MB # min 800kB
#max_prepared_transactions = 0 # zero disables the feature
# (change requires restart)
# Caution: it is not advisable to set max_prepared_transactions nonzero unless
# you actively intend to use prepared transactions.
#work_mem = 4MB # min 64kB
#hash_mem_multiplier = 2.0 # 1-1000.0 multiplier on hash table work_mem
#maintenance_work_mem = 64MB # min 64kB
#autovacuum_work_mem = -1 # min 64kB, or -1 to use maintenance_work_mem
#logical_decoding_work_mem = 64MB # min 64kB
#max_stack_depth = 2MB # min 100kB
#shared_memory_type = mmap # the default is the first option
# supported by the operating system:
# mmap
# sysv
# windows
# (change requires restart)
#dynamic_shared_memory_type = posix # the default is usually the first option
# supported by the operating system:
# posix
# sysv
# windows
# mmap
# (change requires restart)
#min_dynamic_shared_memory = 0MB # (change requires restart)
#vacuum_buffer_usage_limit = 2MB # size of vacuum and analyze buffer access strategy ring;
# 0 to disable vacuum buffer access strategy;
# range 128kB to 16GB
# SLRU buffers (change requires restart)
#commit_timestamp_buffers = 0 # memory for pg_commit_ts (0 = auto)
#multixact_offset_buffers = 16 # memory for pg_multixact/offsets
#multixact_member_buffers = 32 # memory for pg_multixact/members
#notify_buffers = 16 # memory for pg_notify
#serializable_buffers = 32 # memory for pg_serial
#subtransaction_buffers = 0 # memory for pg_subtrans (0 = auto)
#transaction_buffers = 0 # memory for pg_xact (0 = auto)
# - Disk -
#temp_file_limit = -1 # limits per-process temp file space
# in kilobytes, or -1 for no limit
#max_notify_queue_pages = 1048576 # limits the number of SLRU pages allocated
# for NOTIFY / LISTEN queue
# - Kernel Resources -
#max_files_per_process = 1000 # min 64
# (change requires restart)
# - Cost-Based Vacuum Delay -
#vacuum_cost_delay = 0 # 0-100 milliseconds (0 disables)
#vacuum_cost_page_hit = 1 # 0-10000 credits
#vacuum_cost_page_miss = 2 # 0-10000 credits
#vacuum_cost_page_dirty = 20 # 0-10000 credits
#vacuum_cost_limit = 200 # 1-10000 credits
# - Background Writer -
#bgwriter_delay = 200ms # 10-10000ms between rounds
#bgwriter_lru_maxpages = 100 # max buffers written/round, 0 disables
#bgwriter_lru_multiplier = 2.0 # 0-10.0 multiplier on buffers scanned/round
#bgwriter_flush_after = 0 # measured in pages, 0 disables
# - Asynchronous Behavior -
#backend_flush_after = 0 # measured in pages, 0 disables
#effective_io_concurrency = 1 # 1-1000; 0 disables prefetching
#maintenance_io_concurrency = 10 # 1-1000; 0 disables prefetching
#io_combine_limit = 128kB # usually 1-32 blocks (depends on OS)
#max_worker_processes = 8 # (change requires restart)
#max_parallel_workers_per_gather = 2 # limited by max_parallel_workers
#max_parallel_maintenance_workers = 2 # limited by max_parallel_workers
#max_parallel_workers = 8 # number of max_worker_processes that
# can be used in parallel operations
#parallel_leader_participation = on
#------------------------------------------------------------------------------
# WRITE-AHEAD LOG
#------------------------------------------------------------------------------
# - Settings -
#wal_level = replica # minimal, replica, or logical
# (change requires restart)
#fsync = on # flush data to disk for crash safety
# (turning this off can cause
# unrecoverable data corruption)
#synchronous_commit = on # synchronization level;
# off, local, remote_write, remote_apply, or on
#wal_sync_method = fsync # the default is the first option
# supported by the operating system:
# open_datasync
# fdatasync (default on Linux and FreeBSD)
# fsync
# fsync_writethrough
# open_sync
#full_page_writes = on # recover from partial page writes
#wal_log_hints = off # also do full page writes of non-critical updates
# (change requires restart)
#wal_compression = off # enables compression of full-page writes;
# off, pglz, lz4, zstd, or on
#wal_init_zero = on # zero-fill new WAL files
#wal_recycle = on # recycle WAL files
#wal_buffers = -1 # min 32kB, -1 sets based on shared_buffers
# (change requires restart)
#wal_writer_delay = 200ms # 1-10000 milliseconds
#wal_writer_flush_after = 1MB # measured in pages, 0 disables
#wal_skip_threshold = 2MB
#commit_delay = 0 # range 0-100000, in microseconds
#commit_siblings = 5 # range 1-1000
# - Checkpoints -
#checkpoint_timeout = 5min # range 30s-1d
#checkpoint_completion_target = 0.9 # checkpoint target duration, 0.0 - 1.0
#checkpoint_flush_after = 0 # measured in pages, 0 disables
#checkpoint_warning = 30s # 0 disables
#max_wal_size = 1GB
#min_wal_size = 80MB
# - Prefetching during recovery -
#recovery_prefetch = try # prefetch pages referenced in the WAL?
#wal_decode_buffer_size = 512kB # lookahead window used for prefetching
# (change requires restart)
# - Archiving -
#archive_mode = off # enables archiving; off, on, or always
# (change requires restart)
#archive_library = '' # library to use to archive a WAL file
# (empty string indicates archive_command should
# be used)
#archive_command = '' # command to use to archive a WAL file
# placeholders: %p = path of file to archive
# %f = file name only
# e.g. 'test ! -f /mnt/server/archivedir/%f && cp %p /mnt/server/archivedir/%f'
#archive_timeout = 0 # force a WAL file switch after this
# number of seconds; 0 disables
# - Archive Recovery -
# These are only used in recovery mode.
#restore_command = '' # command to use to restore an archived WAL file
# placeholders: %p = path of file to restore
# %f = file name only
# e.g. 'cp /mnt/server/archivedir/%f %p'
#archive_cleanup_command = '' # command to execute at every restartpoint
#recovery_end_command = '' # command to execute at completion of recovery
# - Recovery Target -
# Set these only when performing a targeted recovery.
#recovery_target = '' # 'immediate' to end recovery as soon as a
# consistent state is reached
# (change requires restart)
#recovery_target_name = '' # the named restore point to which recovery will proceed
# (change requires restart)
#recovery_target_time = '' # the time stamp up to which recovery will proceed
# (change requires restart)
#recovery_target_xid = '' # the transaction ID up to which recovery will proceed
# (change requires restart)
#recovery_target_lsn = '' # the WAL LSN up to which recovery will proceed
# (change requires restart)
#recovery_target_inclusive = on # Specifies whether to stop:
# just after the specified recovery target (on)
# just before the recovery target (off)
# (change requires restart)
#recovery_target_timeline = 'latest' # 'current', 'latest', or timeline ID
# (change requires restart)
#recovery_target_action = 'pause' # 'pause', 'promote', 'shutdown'
# (change requires restart)
# - WAL Summarization -
#summarize_wal = off # run WAL summarizer process?
#wal_summary_keep_time = '10d' # when to remove old summary files, 0 = never
#------------------------------------------------------------------------------
# REPLICATION
#------------------------------------------------------------------------------
# - Sending Servers -
# Set these on the primary and on any standby that will send replication data.
#max_wal_senders = 10 # max number of walsender processes
# (change requires restart)
#max_replication_slots = 10 # max number of replication slots
# (change requires restart)
#wal_keep_size = 0 # in megabytes; 0 disables
#max_slot_wal_keep_size = -1 # in megabytes; -1 disables
#wal_sender_timeout = 60s # in milliseconds; 0 disables
#track_commit_timestamp = off # collect timestamp of transaction commit
# (change requires restart)
# - Primary Server -
# These settings are ignored on a standby server.
#synchronous_standby_names = '' # standby servers that provide sync rep
# method to choose sync standbys, number of sync standbys,
# and comma-separated list of application_name
# from standby(s); '*' = all
#synchronized_standby_slots = '' # streaming replication standby server slot
# names that logical walsender processes will wait for
# - Standby Servers -
# These settings are ignored on a primary server.
#primary_conninfo = '' # connection string to sending server
#primary_slot_name = '' # replication slot on sending server
#hot_standby = on # "off" disallows queries during recovery
# (change requires restart)
#max_standby_archive_delay = 30s # max delay before canceling queries
# when reading WAL from archive;
# -1 allows indefinite delay
#max_standby_streaming_delay = 30s # max delay before canceling queries
# when reading streaming WAL;
# -1 allows indefinite delay
#wal_receiver_create_temp_slot = off # create temp slot if primary_slot_name
# is not set
#wal_receiver_status_interval = 10s # send replies at least this often
# 0 disables
#hot_standby_feedback = off # send info from standby to prevent
# query conflicts
#wal_receiver_timeout = 60s # time that receiver waits for
# communication from primary
# in milliseconds; 0 disables
#wal_retrieve_retry_interval = 5s # time to wait before retrying to
# retrieve WAL after a failed attempt
#recovery_min_apply_delay = 0 # minimum delay for applying changes during recovery
#sync_replication_slots = off # enables slot synchronization on the physical standby from the primary
# - Subscribers -
# These settings are ignored on a publisher.
#max_logical_replication_workers = 4 # taken from max_worker_processes
# (change requires restart)
#max_sync_workers_per_subscription = 2 # taken from max_logical_replication_workers
#max_parallel_apply_workers_per_subscription = 2 # taken from max_logical_replication_workers
#------------------------------------------------------------------------------
# QUERY TUNING
#------------------------------------------------------------------------------
# - Planner Method Configuration -
#enable_async_append = on
#enable_bitmapscan = on
#enable_gathermerge = on
#enable_hashagg = on
#enable_hashjoin = on
#enable_incremental_sort = on
#enable_indexscan = on
#enable_indexonlyscan = on
#enable_material = on
#enable_memoize = on
#enable_mergejoin = on
#enable_nestloop = on
#enable_parallel_append = on
#enable_parallel_hash = on
#enable_partition_pruning = on
#enable_partitionwise_join = off
#enable_partitionwise_aggregate = off
#enable_presorted_aggregate = on
#enable_seqscan = on
#enable_sort = on
#enable_tidscan = on
#enable_group_by_reordering = on
# - Planner Cost Constants -
#seq_page_cost = 1.0 # measured on an arbitrary scale
#random_page_cost = 4.0 # same scale as above
#cpu_tuple_cost = 0.01 # same scale as above
#cpu_index_tuple_cost = 0.005 # same scale as above
#cpu_operator_cost = 0.0025 # same scale as above
#parallel_setup_cost = 1000.0 # same scale as above
#parallel_tuple_cost = 0.1 # same scale as above
#min_parallel_table_scan_size = 8MB
#min_parallel_index_scan_size = 512kB
#effective_cache_size = 4GB
#jit_above_cost = 100000 # perform JIT compilation if available
# and query more expensive than this;
# -1 disables
#jit_inline_above_cost = 500000 # inline small functions if query is
# more expensive than this; -1 disables
#jit_optimize_above_cost = 500000 # use expensive JIT optimizations if
# query is more expensive than this;
# -1 disables
# - Genetic Query Optimizer -
#geqo = on
#geqo_threshold = 12
#geqo_effort = 5 # range 1-10
#geqo_pool_size = 0 # selects default based on effort
#geqo_generations = 0 # selects default based on effort
#geqo_selection_bias = 2.0 # range 1.5-2.0
#geqo_seed = 0.0 # range 0.0-1.0
# - Other Planner Options -
#default_statistics_target = 100 # range 1-10000
#constraint_exclusion = partition # on, off, or partition
#cursor_tuple_fraction = 0.1 # range 0.0-1.0
#from_collapse_limit = 8
#jit = on # allow JIT compilation
#join_collapse_limit = 8 # 1 disables collapsing of explicit
# JOIN clauses
#plan_cache_mode = auto # auto, force_generic_plan or
# force_custom_plan
#recursive_worktable_factor = 10.0 # range 0.001-1000000
#------------------------------------------------------------------------------
# REPORTING AND LOGGING
#------------------------------------------------------------------------------
# - Where to Log -
#log_destination = 'stderr' # Valid values are combinations of
# stderr, csvlog, jsonlog, syslog, and
# eventlog, depending on platform.
# csvlog and jsonlog require
# logging_collector to be on.
# This is used when logging to stderr:
#logging_collector = off # Enable capturing of stderr, jsonlog,
# and csvlog into log files. Required
# to be on for csvlogs and jsonlogs.
# (change requires restart)
# These are only used if logging_collector is on:
log_directory = '/var/lib/postgresql/data/pg_log' # directory where log files are written,
# can be absolute or relative to PGDATA
log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' # log file name pattern,
# can include strftime() escapes
#log_file_mode = 0600 # creation mode for log files,
# begin with 0 to use octal notation
#log_rotation_age = 1d # Automatic rotation of logfiles will
# happen after that time. 0 disables.
#log_rotation_size = 10MB # Automatic rotation of logfiles will
# happen after that much log output.
# 0 disables.
#log_truncate_on_rotation = off # If on, an existing log file with the
# same name as the new log file will be
# truncated rather than appended to.
# But such truncation only occurs on
# time-driven rotation, not on restarts
# or size-driven rotation. Default is
# off, meaning append to existing files
# in all cases.
# These are relevant when logging to syslog:
#syslog_facility = 'LOCAL0'
#syslog_ident = 'postgres'
#syslog_sequence_numbers = on
#syslog_split_messages = on
# This is only relevant when logging to eventlog (Windows):
# (change requires restart)
#event_source = 'PostgreSQL'
# - When to Log -
#log_min_messages = warning # values in order of decreasing detail:
# debug5
# debug4
# debug3
# debug2
# debug1
# info
# notice
# warning
# error
# log
# fatal
# panic
#log_min_error_statement = error # values in order of decreasing detail:
# debug5
# debug4
# debug3
# debug2
# debug1
# info
# notice
# warning
# error
# log
# fatal
# panic (effectively off)
#log_min_duration_statement = -1 # -1 is disabled, 0 logs all statements
# and their durations, > 0 logs only
# statements running at least this number
# of milliseconds
#log_min_duration_sample = -1 # -1 is disabled, 0 logs a sample of statements
# and their durations, > 0 logs only a sample of
# statements running at least this number
# of milliseconds;
# sample fraction is determined by log_statement_sample_rate
#log_statement_sample_rate = 1.0 # fraction of logged statements exceeding
# log_min_duration_sample to be logged;
# 1.0 logs all such statements, 0.0 never logs
#log_transaction_sample_rate = 0.0 # fraction of transactions whose statements
# are logged regardless of their duration; 1.0 logs all
# statements from all transactions, 0.0 never logs
#log_startup_progress_interval = 10s # Time between progress updates for
# long-running startup operations.
# 0 disables the feature, > 0 indicates
# the interval in milliseconds.
# - What to Log -
#debug_print_parse = off
#debug_print_rewritten = off
#debug_print_plan = off
#debug_pretty_print = on
#log_autovacuum_min_duration = 10min # log autovacuum activity;
# -1 disables, 0 logs all actions and
# their durations, > 0 logs only
# actions running at least this number
# of milliseconds.
#log_checkpoints = on
#log_connections = off
#log_disconnections = off
#log_duration = off
#log_error_verbosity = default # terse, default, or verbose messages
#log_hostname = off
#log_line_prefix = '%m [%p] ' # special values:
# %a = application name
# %u = user name
# %d = database name
# %r = remote host and port
# %h = remote host
# %b = backend type
# %p = process ID
# %P = process ID of parallel group leader
# %t = timestamp without milliseconds
# %m = timestamp with milliseconds
# %n = timestamp with milliseconds (as a Unix epoch)
# %Q = query ID (0 if none or not computed)
# %i = command tag
# %e = SQL state
# %c = session ID
# %l = session line number
# %s = session start timestamp
# %v = virtual transaction ID
# %x = transaction ID (0 if none)
# %q = stop here in non-session
# processes
# %% = '%'
# e.g. '<%u%%%d> '
#log_lock_waits = off # log lock waits >= deadlock_timeout
#log_recovery_conflict_waits = off # log standby recovery conflict waits
# >= deadlock_timeout
#log_parameter_max_length = -1 # when logging statements, limit logged
# bind-parameter values to N bytes;
# -1 means print in full, 0 disables
#log_parameter_max_length_on_error = 0 # when logging an error, limit logged
# bind-parameter values to N bytes;
# -1 means print in full, 0 disables
log_statement = 'all' # none, ddl, mod, all
#log_replication_commands = off
#log_temp_files = -1 # log temporary files equal or larger
# than the specified size in kilobytes;
# -1 disables, 0 logs all temp files
#log_timezone = 'GMT'
# - Process Title -
#cluster_name = '' # added to process titles if nonempty
# (change requires restart)
#update_process_title = on
#------------------------------------------------------------------------------
# STATISTICS
#------------------------------------------------------------------------------
# - Cumulative Query and Index Statistics -
#track_activities = on
#track_activity_query_size = 1024 # (change requires restart)
#track_counts = on
#track_io_timing = off
#track_wal_io_timing = off
#track_functions = none # none, pl, all
#stats_fetch_consistency = cache # cache, none, snapshot
# - Monitoring -
#compute_query_id = auto
#log_statement_stats = off
#log_parser_stats = off
#log_planner_stats = off
#log_executor_stats = off
#------------------------------------------------------------------------------
# AUTOVACUUM
#------------------------------------------------------------------------------
#autovacuum = on # Enable autovacuum subprocess? 'on'
# requires track_counts to also be on.
#autovacuum_max_workers = 3 # max number of autovacuum subprocesses
# (change requires restart)
#autovacuum_naptime = 1min # time between autovacuum runs
#autovacuum_vacuum_threshold = 50 # min number of row updates before
# vacuum
#autovacuum_vacuum_insert_threshold = 1000 # min number of row inserts
# before vacuum; -1 disables insert
# vacuums
#autovacuum_analyze_threshold = 50 # min number of row updates before
# analyze
#autovacuum_vacuum_scale_factor = 0.2 # fraction of table size before vacuum
#autovacuum_vacuum_insert_scale_factor = 0.2 # fraction of inserts over table
# size before insert vacuum
#autovacuum_analyze_scale_factor = 0.1 # fraction of table size before analyze
#autovacuum_freeze_max_age = 200000000 # maximum XID age before forced vacuum
# (change requires restart)
#autovacuum_multixact_freeze_max_age = 400000000 # maximum multixact age
# before forced vacuum
# (change requires restart)
#autovacuum_vacuum_cost_delay = 2ms # default vacuum cost delay for
# autovacuum, in milliseconds;
# -1 means use vacuum_cost_delay
#autovacuum_vacuum_cost_limit = -1 # default vacuum cost limit for
# autovacuum, -1 means use
# vacuum_cost_limit
#------------------------------------------------------------------------------
# CLIENT CONNECTION DEFAULTS
#------------------------------------------------------------------------------
# - Statement Behavior -
#client_min_messages = notice # values in order of decreasing detail:
# debug5
# debug4
# debug3
# debug2
# debug1
# log
# notice
# warning
# error
#search_path = '"$user", public' # schema names
#row_security = on
#default_table_access_method = 'heap'
#default_tablespace = '' # a tablespace name, '' uses the default
#default_toast_compression = 'pglz' # 'pglz' or 'lz4'
#temp_tablespaces = '' # a list of tablespace names, '' uses
# only default tablespace
#check_function_bodies = on
#default_transaction_isolation = 'read committed'
#default_transaction_read_only = off
#default_transaction_deferrable = off
#session_replication_role = 'origin'
#statement_timeout = 0 # in milliseconds, 0 is disabled
#transaction_timeout = 0 # in milliseconds, 0 is disabled
#lock_timeout = 0 # in milliseconds, 0 is disabled
#idle_in_transaction_session_timeout = 0 # in milliseconds, 0 is disabled
#idle_session_timeout = 0 # in milliseconds, 0 is disabled
#vacuum_freeze_table_age = 150000000
#vacuum_freeze_min_age = 50000000
#vacuum_failsafe_age = 1600000000
#vacuum_multixact_freeze_table_age = 150000000
#vacuum_multixact_freeze_min_age = 5000000
#vacuum_multixact_failsafe_age = 1600000000
#bytea_output = 'hex' # hex, escape
#xmlbinary = 'base64'
#xmloption = 'content'
#gin_pending_list_limit = 4MB
#createrole_self_grant = '' # set and/or inherit
#event_triggers = on
# - Locale and Formatting -
#datestyle = 'iso, mdy'
#intervalstyle = 'postgres'
#timezone = 'GMT'
#timezone_abbreviations = 'Default' # Select the set of available time zone
# abbreviations. Currently, there are
# Default
# Australia (historical usage)
# India
# You can create your own file in
# share/timezonesets/.
#extra_float_digits = 1 # min -15, max 3; any value >0 actually
# selects precise output mode
#client_encoding = sql_ascii # actually, defaults to database
# encoding
# These settings are initialized by initdb, but they can be changed.
#lc_messages = '' # locale for system error message
# strings
#lc_monetary = 'C' # locale for monetary formatting
#lc_numeric = 'C' # locale for number formatting
#lc_time = 'C' # locale for time formatting
#icu_validation_level = warning # report ICU locale validation
# errors at the given level
# default configuration for text search
#default_text_search_config = 'pg_catalog.simple'
# - Shared Library Preloading -
#local_preload_libraries = ''
#session_preload_libraries = ''
#shared_preload_libraries = '' # (change requires restart)
#jit_provider = 'llvmjit' # JIT library to use
# - Other Defaults -
#dynamic_library_path = '$libdir'
#extension_destdir = '' # prepend path when loading extensions
# and shared objects (added by Debian)
#gin_fuzzy_search_limit = 0
#------------------------------------------------------------------------------
# LOCK MANAGEMENT
#------------------------------------------------------------------------------
#deadlock_timeout = 1s
#max_locks_per_transaction = 64 # min 10
# (change requires restart)
#max_pred_locks_per_transaction = 64 # min 10
# (change requires restart)
#max_pred_locks_per_relation = -2 # negative values mean
# (max_pred_locks_per_transaction
# / -max_pred_locks_per_relation) - 1
#max_pred_locks_per_page = 2 # min 0
#------------------------------------------------------------------------------
# VERSION AND PLATFORM COMPATIBILITY
#------------------------------------------------------------------------------
# - Previous PostgreSQL Versions -
#array_nulls = on
#backslash_quote = safe_encoding # on, off, or safe_encoding
#escape_string_warning = on
#lo_compat_privileges = off
#quote_all_identifiers = off
#standard_conforming_strings = on
#synchronize_seqscans = on
# - Other Platforms and Clients -
#transform_null_equals = off
#allow_alter_system = on
#------------------------------------------------------------------------------
# ERROR HANDLING
#------------------------------------------------------------------------------
#exit_on_error = off # terminate session on any error?
#restart_after_crash = on # reinitialize after backend crash?
#data_sync_retry = off # retry or panic on failure to fsync
# data?
# (change requires restart)
#recovery_init_sync_method = fsync # fsync, syncfs (Linux 5.8+)
#------------------------------------------------------------------------------
# CONFIG FILE INCLUDES
#------------------------------------------------------------------------------
# These options allow settings to be loaded from files other than the
# default postgresql.conf. Note that these are directives, not variable
# assignments, so they can usefully be given more than once.
#include_dir = '...' # include files ending in '.conf' from
# a directory, e.g., 'conf.d'
#include_if_exists = '...' # include file only if it exists
#include = '...' # include file
#------------------------------------------------------------------------------
# CUSTOMIZED OPTIONS
#------------------------------------------------------------------------------
# Add settings for extensions here

View File

@@ -0,0 +1 @@
"user" "password"

View File

@@ -0,0 +1,260 @@
import { readdirSync, readFileSync } from 'fs'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import pg from 'pg'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
// Focused integration test for the effect_outbox trigger (migrations 047, 048), the
// generic transactional outbox that TLFileEffectProcessor (sync-worker) drains. The
// design-critical case is #5 below: cleanup_deleted_group_trigger (023_groups.sql)
// soft-deletes group-owned files from inside a plpgsql trigger when a group is
// deleted, and file_effect_outbox_after_change must still fire for that UPDATE —
// a trigger-on-trigger cascade that only a real Postgres can exercise.
//
// This talks to a real postgres (the triggers are plpgsql, so fakes can't exercise
// them). It is opt-in: set ZERO_CACHE_TEST_POSTGRES_URL (local dev stack:
// postgres://user:password@localhost:6543/postgres) to run it. Without a connection
// string the suite is skipped so CI stays green.
//
// Isolation differs from stamp_comment_created_at.test.ts / delete_file_states.test.ts:
// those pin `search_path` to a throwaway schema because their migration SQL uses
// unqualified table names. 047/048 hardcode `public.` (CREATE TABLE public.effect_outbox,
// CREATE TRIGGER ... ON public.file), so search_path can't redirect them — running them
// verbatim always targets the connection's `public` schema. Since ZERO_CACHE_TEST_POSTGRES_URL
// points at a real local dev stack whose `public` schema holds real dev data, this suite
// instead creates a throwaway database with CREATE DATABASE, and drops it in afterAll.
//
// Owning a whole database also means the REAL migration chain (000 onward) can run
// verbatim, exactly like migrate.ts applies it. Every trigger involved — the outbox
// trigger, the group-delete cascade, the updatedAt bumper (005), the owner-details
// denormalizers — is the shipped one, not a test copy, so a behavior change in any
// migration is caught here.
const CONNECTION_STRING = process.env.ZERO_CACHE_TEST_POSTGRES_URL
const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'migrations')
// The full, shipped migration chain in filename order — the same ordering migrate.ts
// uses (readdirSync().sort()).
const MIGRATION_FILES = readdirSync(MIGRATIONS_DIR)
.filter((f) => f.endsWith('.sql'))
.sort()
const dbName = `tldraw_test_outbox_${process.pid}`
const describeMaybe = CONNECTION_STRING ? describe : describe.skip
if (!CONNECTION_STRING) {
// eslint-disable-next-line no-console
console.warn(
'effect_outbox.test.ts: skipping — TLFileEffectProcessor depends on this trigger; set ' +
'ZERO_CACHE_TEST_POSTGRES_URL to verify it against a real postgres.'
)
}
describeMaybe('effect_outbox trigger (file changes + group-delete cascade)', () => {
// One client on the admin connection (for CREATE/DROP DATABASE, which cannot run
// inside a transaction) and one on the throwaway database (where the migration
// chain and all DML run un-namespaced, matching production).
let adminClient: pg.Client
let client: pg.Client
beforeAll(async () => {
adminClient = new pg.Client({ connectionString: CONNECTION_STRING })
await adminClient.connect()
await adminClient.query(`DROP DATABASE IF EXISTS "${dbName}"`)
await adminClient.query(`CREATE DATABASE "${dbName}"`)
const url = new URL(CONNECTION_STRING!)
url.pathname = `/${dbName}`
client = new pg.Client({ connectionString: url.toString() })
await client.connect()
// Apply the real migration chain, in order, verbatim.
for (const filename of MIGRATION_FILES) {
const sql = readFileSync(join(MIGRATIONS_DIR, filename), 'utf8')
try {
await client.query(sql)
} catch (err) {
throw new Error(`Migration ${filename} failed: ${(err as Error).message}`)
}
}
})
afterAll(async () => {
if (client) await client.end()
if (adminClient) {
await adminClient.query(`DROP DATABASE IF EXISTS "${dbName}"`)
await adminClient.end()
}
})
beforeEach(async () => {
// TRUNCATE fires no row-level triggers, so resetting state writes no outbox rows.
// CASCADE clears referencing tables (file_state, group_file, group_user, ...).
await client.query(`TRUNCATE "user", "file", "group", effect_outbox CASCADE`)
})
// Also creates the user's home workspace, mirroring production, where a home group's id is
// the user's own id. `file."owningGroupId"` is NOT NULL since 050, so every file needs one.
async function seedUser(id: string) {
await client.query(
`INSERT INTO "user" ("id", "name", "email", "avatar", "color", "exportFormat", "exportTheme",
"exportBackground", "exportPadding", "createdAt", "updatedAt", "flags")
VALUES ($1, $1, $1, '', '', 'png', 'auto', false, false, 0, 0, '')`,
[id]
)
await client.query(`INSERT INTO "group" ("id", "name") VALUES ($1, $1)`, [id])
}
async function seedFile(id: string, overrides: { owningGroupId?: string } = {}) {
const owningGroupId = overrides.owningGroupId ?? 'u1'
await client.query(
`INSERT INTO "file" ("id", "name", "owningGroupId", "thumbnail", "shared",
"sharedLinkType", "published", "lastPublished", "publishedSlug", "createdAt",
"updatedAt", "isEmpty")
VALUES ($1, $1, $2, '', false, 'view', false, 0, $1, 0, 0, false)`,
[id, owningGroupId]
)
}
interface OutboxRow {
tableName: string
entityId: string
command: string
payload: any
prevPayload: any
attempts: number
nextRetryAt: string | null
lastError: string | null
}
async function outboxRows(entityId: string): Promise<OutboxRow[]> {
const res = await client.query<OutboxRow>(
`SELECT "tableName", "entityId", command, payload, "prevPayload", attempts, "nextRetryAt",
"lastError"
FROM effect_outbox WHERE "entityId" = $1 ORDER BY id`,
[entityId]
)
return res.rows
}
it('file INSERT produces one outbox row with the new row as payload and no prevPayload', async () => {
await seedUser('u1')
await seedFile('f1')
const rows = await outboxRows('f1')
expect(rows).toHaveLength(1)
expect(rows[0].tableName).toBe('file')
expect(rows[0].command).toBe('insert')
expect(rows[0].payload.id).toBe('f1')
expect(rows[0].prevPayload).toBeNull()
})
it('a no-op update (bumping only updatedAt) produces no new outbox row', async () => {
await seedUser('u1')
await seedFile('f1')
// This is the room-persist path: sync-worker bumps updatedAt every few seconds
// per active room. The 005 trigger rewrites updatedAt to now() on any distinct
// update, so only updatedAt differs between OLD and NEW — the outbox filter
// must treat that as a no-op.
await client.query(`UPDATE "file" SET "updatedAt" = 12345 WHERE "id" = 'f1'`)
const rows = await outboxRows('f1')
// only the insert row from seedFile
expect(rows).toHaveLength(1)
expect(rows[0].command).toBe('insert')
})
it('an effect-relevant update (publish) produces an update row carrying the full OLD row as prevPayload', async () => {
await seedUser('u1')
await seedFile('f1')
await client.query(
`UPDATE "file" SET "published" = true, "lastPublished" = 999 WHERE "id" = 'f1'`
)
const rows = await outboxRows('f1')
expect(rows).toHaveLength(2)
const updateRow = rows[1]
expect(updateRow.command).toBe('update')
expect(updateRow.payload.published).toBe(true)
expect(updateRow.payload.lastPublished).toBe(999)
// prevPayload is the full OLD row: changed columns show their old values,
// unchanged columns are present and equal
expect(updateRow.prevPayload.published).toBe(false)
expect(updateRow.prevPayload.lastPublished).toBe(0)
expect(updateRow.prevPayload.id).toBe('f1')
expect(updateRow.prevPayload.name).toBe('f1')
expect(updateRow.prevPayload.owningGroupId).toBe('u1')
})
it('file DELETE produces a delete row with the OLD row as payload and no prevPayload', async () => {
await seedUser('u1')
await seedFile('f1')
await client.query(`DELETE FROM "file" WHERE "id" = 'f1'`)
const rows = await outboxRows('f1')
expect(rows).toHaveLength(2)
const deleteRow = rows[1]
expect(deleteRow.command).toBe('delete')
expect(deleteRow.payload.id).toBe('f1')
expect(deleteRow.prevPayload).toBeNull()
})
it('deleting a group cascades to a soft-delete update on its files, and the outbox trigger fires for it', async () => {
await seedUser('u1')
await client.query(`INSERT INTO "group" ("id", "name") VALUES ('g1', 'g1')`)
await seedFile('fGroup', { owningGroupId: 'g1' })
// The real cleanup_deleted_group_trigger (023_groups.sql) fires inside this
// UPDATE and soft-deletes fGroup, which in turn must fire
// file_effect_outbox_after_change for that nested UPDATE.
await client.query(`UPDATE "group" SET "isDeleted" = true WHERE "id" = 'g1'`)
const rows = await outboxRows('fGroup')
expect(rows).toHaveLength(2)
const cascadeRow = rows[1]
expect(cascadeRow.tableName).toBe('file')
expect(cascadeRow.command).toBe('update')
expect(cascadeRow.payload.isDeleted).toBe(true)
expect(cascadeRow.prevPayload.isDeleted).toBe(false)
})
it('new rows start with attempts = 0 and nextRetryAt IS NULL (048 applied)', async () => {
await seedUser('u1')
await seedFile('f1')
const rows = await outboxRows('f1')
expect(rows).toHaveLength(1)
expect(rows[0].attempts).toBe(0)
expect(rows[0].nextRetryAt).toBeNull()
})
it('new rows start with lastError IS NULL and a failed attempt can record it (051 applied)', async () => {
await seedUser('u1')
await seedFile('f1')
expect((await outboxRows('f1'))[0].lastError).toBeNull()
// The same statement shape the DO's bumpAttempts issues: attempts + 1 and lastError in one
// UPDATE. A later attempt overwrites; an admin retry (attempts = 0) leaves it in place.
const bump = (error: string) =>
client.query(
`UPDATE effect_outbox SET attempts = attempts + 1, "lastError" = $2 WHERE "entityId" = $1`,
['f1', error]
)
await bump('EffectTimeoutError: timed out after 30000ms')
await bump('Error: proxy request failed')
let [row] = await outboxRows('f1')
expect(row.attempts).toBe(2)
expect(row.lastError).toBe('Error: proxy request failed')
await client.query(
`UPDATE effect_outbox SET attempts = 0, "nextRetryAt" = NULL WHERE "entityId" = $1`,
['f1']
)
;[row] = await outboxRows('f1')
expect(row.attempts).toBe(0)
expect(row.lastError).toBe('Error: proxy request failed')
})
})

View File

@@ -0,0 +1,47 @@
app = "__APP_NAME"
primary_region = "fra"
kill_timeout = "__KILL_TIMEOUT"
[build]
image = "registry.hub.docker.com/rocicorp/zero:__ZERO_VERSION"
# Do not add [http_service] or [[services]] to this app. The replication-manager
# serves Zero's unauthenticated change-streamer protocol on port 4849 and must
# only be reachable over Fly's private network from view-syncers.
[checks]
[checks.keepalive]
type = "http"
port = 4849
grace_period = "30s"
interval = "5s"
timeout = "5s"
path = "/keepalive"
[[restart]]
policy = "always"
[[vm]]
memory = "__VM_MEMORY"
cpu_kind = "__CPU_KIND"
cpus = __VM_CPUS
[mounts]
source = "sqlite_repl"
destination = "/data"
initial_size = "__VOLUME_SIZE"
[env]
ZERO_REPLICA_FILE = "/data/sync-replica.db"
ZERO_APP_PUBLICATIONS = "zero_data"
ZERO_NUM_SYNC_WORKERS = "0"
ZERO_UPSTREAM_MAX_CONNS = "__RM_UPSTREAM_MAX_CONNS"
ZERO_CVR_MAX_CONNS = "__RM_CVR_MAX_CONNS"
ZERO_CHANGE_MAX_CONNS = "__RM_CHANGE_MAX_CONNS"
ZERO_LITESTREAM_BACKUP_URL = "s3://__ZERO_R2_BUCKET_NAME/__BACKUP_PATH"
ZERO_LITESTREAM_ENDPOINT = "__ZERO_R2_ENDPOINT"
ZERO_LOG_LEVEL = "warn"
OTEL_TRACES_EXPORTER = "none"
DO_NOT_TRACK = "1"
OTEL_NODE_RESOURCE_DETECTORS = "env,host,os"
OTEL_RESOURCE_ATTRIBUTES = "service.name=zero-rm,service.namespace=dotcom,deployment.environment=__TLDRAW_ENV,service.version=__ZERO_VERSION"

View File

@@ -0,0 +1,54 @@
app = "__APP_NAME"
primary_region = "fra"
kill_timeout = "__KILL_TIMEOUT"
[build]
dockerfile = "Dockerfile"
[http_service]
internal_port = 4848
force_https = true
auto_stop_machines = "off"
min_machines_running = __VS_MIN_MACHINES
[[http_service.http_options.replay_cache]]
path_prefix = "/"
ttl_seconds = 3600
type = "cookie"
name = "fly_machine_id"
[[http_service.checks]]
grace_period = "10s"
interval = "5s"
method = "GET"
timeout = "5s"
path = "/"
[[vm]]
memory = "__VM_MEMORY"
cpu_kind = "__CPU_KIND"
cpus = __VM_CPUS
[mounts]
source = "sqlite_db"
destination = "/data"
initial_size = "__VOLUME_SIZE"
[env]
ZERO_REPLICA_FILE = "/data/sync-replica.db"
ZERO_APP_PUBLICATIONS = "zero_data"
ZERO_CHANGE_STREAMER_URI = "__REPLICATION_MANAGER_URI"
ZERO_MUTATE_URL = "__ZERO_MUTATE_URL"
ZERO_QUERY_URL = "__ZERO_QUERY_URL"
ZERO_ENABLE_CRUD_MUTATIONS = "false"
ZERO_UPSTREAM_MAX_CONNS = "__VS_UPSTREAM_MAX_CONNS"
ZERO_CVR_MAX_CONNS = "__VS_CVR_MAX_CONNS"
ZERO_CHANGE_MAX_CONNS = "__VS_CHANGE_MAX_CONNS"
ZERO_LOG_LEVEL = "warn"
OTEL_TRACES_EXPORTER = "none"
DO_NOT_TRACK = "1"
OTEL_NODE_RESOURCE_DETECTORS = "env,host,os"
OTEL_RESOURCE_ATTRIBUTES = "service.name=zero-vs,service.namespace=dotcom,deployment.environment=__TLDRAW_ENV,service.version=__ZERO_VERSION"
ZERO_LITESTREAM_CONFIG_PATH = "/etc/litestream.yml"
ZERO_LITESTREAM_BACKUP_URL = "s3://__ZERO_R2_BUCKET_NAME/__BACKUP_PATH"
ZERO_LITESTREAM_ENDPOINT = "__ZERO_R2_ENDPOINT"

View File

@@ -0,0 +1,46 @@
app = "__APP_NAME"
primary_region = "fra"
[build]
image = "registry.hub.docker.com/rocicorp/zero:__ZERO_VERSION"
[http_service]
internal_port = 4848
force_https = true
auto_stop_machines = "off"
min_machines_running = 1
[[http_service.checks]]
grace_period = "10s"
interval = "30s"
method = "GET"
timeout = "5s"
path = "/"
[[vm]]
memory = "2gb"
cpu_kind = "shared"
cpus = 2
[mounts]
source = "sqlite_db"
destination = "/data"
initial_size = "1gb"
[env]
ZERO_REPLICA_FILE = "/data/sync-replica.db"
ZERO_UPSTREAM_DB = "__BOTCOM_POSTGRES_CONNECTION_STRING"
ZERO_CVR_DB = "__BOTCOM_POSTGRES_CONNECTION_STRING"
ZERO_CHANGE_DB = "__BOTCOM_POSTGRES_CONNECTION_STRING"
ZERO_APP_PUBLICATIONS = "zero_data"
ZERO_ADMIN_PASSWORD = "__ZERO_ADMIN_PASSWORD"
ZERO_UPSTREAM_MAX_CONNS = "__SINGLE_UPSTREAM_MAX_CONNS"
ZERO_CVR_MAX_CONNS = "__SINGLE_CVR_MAX_CONNS"
ZERO_CHANGE_MAX_CONNS = "__SINGLE_CHANGE_MAX_CONNS"
ZERO_LOG_LEVEL = "warn"
OTEL_TRACES_EXPORTER = "none"
ZERO_MUTATE_URL = "__ZERO_MUTATE_URL"
ZERO_QUERY_URL = "__ZERO_QUERY_URL"
ZERO_ENABLE_CRUD_MUTATIONS = "false"
ZERO_LAZY_STARTUP = 'true'
DO_NOT_TRACK = '1'

View File

@@ -0,0 +1,201 @@
/* eslint-disable no-console */
import { existsSync, readFileSync, readdirSync } from 'fs'
import { createServer } from 'http'
import { Kysely, PostgresDialect, sql } from 'kysely'
import pg from 'pg'
import { hasTransactionBlock } from './migrationSql'
const postgresConnectionString =
process.env.BOTCOM_POSTGRES_POOLED_CONNECTION_STRING ||
'postgresql://user:password@127.0.0.1:6543/postgres'
console.log('Using connection string:', postgresConnectionString)
const migrationsPath = `./migrations`
if (!existsSync(migrationsPath)) {
throw new Error(`Migrations path not found: ${migrationsPath}`)
}
const init = `
CREATE SCHEMA IF NOT EXISTS migrations;
CREATE TABLE IF NOT EXISTS migrations.applied_migrations (
filename VARCHAR PRIMARY KEY,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`
/**
INSERT INTO migrations.applied_migrations (filename) VALUES
('000_seed.sql'),
('001_replicator_boot.sql'),
('002_add_user_id.sql'),
('003_make_published_slug_unique.sql'),
('004_guest_column_on_file_state.sql'),
('005_update_file_trigger.sql'),
('006_add_file_soft_delete.sql'),
('007_update_file_owner_details.sql')
ON CONFLICT DO NOTHING;
*/
// Tell Zero about schema changes so it updates its replica in place instead of a
// full reset (Supabase doesn't fire event triggers for ALTER PUBLICATION). Guarded
// because migrations run before Zero boots, so zero_0 may not exist yet on a fresh
// database. to_regprocedure resolves the no-arg overload (to_regproc returns NULL
// when the name is ambiguous, silently skipping the call).
// https://zero.rocicorp.dev/docs/connecting-to-postgres#schema-change-hooks
const notifyZeroOfSchemaChange = `
DO $$
BEGIN
IF to_regprocedure('zero_0.update_schemas()') IS NOT NULL THEN
PERFORM zero_0.update_schemas();
END IF;
END $$;
`
const shouldSignalSuccess = process.argv.includes('--signal-success')
const dryRun = process.argv.includes('--dry-run')
const DRY_RUN_ROLLBACK = new Error('dry-run-rollback')
const db = new Kysely({
dialect: new PostgresDialect({
pool: new pg.Pool({
connectionString: postgresConnectionString,
application_name: 'migrate',
idleTimeoutMillis: 10_000,
max: 1,
}),
}),
log: ['error'],
})
async function waitForPostgres() {
let attempts = 0
do {
try {
await sql`SELECT 1`.execute(db)
break
} catch (_e) {
if (attempts++ > 100) {
throw new Error('Failed to connect to postgres')
}
console.log('Waiting for postgres' + '.'.repeat(attempts))
await new Promise((resolve) => setTimeout(resolve, 500))
}
// eslint-disable-next-line no-constant-condition
} while (true)
await sql.raw(init).execute(db)
}
async function migrate(summary: string[]) {
await db.transaction().execute(async (tx) => {
const appliedMigrations = await sql<{
filename: string
}>`SELECT filename FROM migrations.applied_migrations`.execute(tx)
const migrations = readdirSync(migrationsPath).sort()
if (migrations.length === 0) {
throw new Error('No migrations found')
}
// check that all applied migrations exist
for (const appliedMigration of appliedMigrations.rows) {
if (!migrations.includes(appliedMigration.filename)) {
throw new Error(`Previously-applied migration ${appliedMigration.filename} not found`)
}
}
// We hardcode the `zero_0` shard schema. If Zero is ever reconfigured with a
// different app id or shard, that name changes and our guards would silently
// stop firing, reverting us to full resets. Fail loudly if it drifts. (No rows
// means Zero hasn't booted yet, expected on a fresh database.)
const shardSchemas = await sql<{ nspname: string }>`
SELECT DISTINCT n.nspname FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE p.proname = 'update_schemas'
`.execute(tx)
const schemaNames = shardSchemas.rows.map((r) => r.nspname)
if (schemaNames.length > 0 && !schemaNames.includes('zero_0')) {
throw new Error(
`Expected Zero shard schema "zero_0" but found ${schemaNames.join(', ')}. Update the hardcoded schema name in migrate.ts and add a new migration to enable ddlDetection on the new schema.`
)
}
// DDL on a replicated table takes ACCESS EXCLUSIVE. Without a lock timeout, one long-running
// reader makes the deploy hang while every room persist queues behind the waiting DDL.
// Failing here aborts the deploy before Zero and the sync-worker roll, and a rerun picks up
// where it left off. SET LOCAL is transaction-scoped, so it holds through a
// transaction-mode pooler.
await sql`SET LOCAL lock_timeout = '10s'`.execute(tx)
let appliedNewMigration = false
for (const migration of migrations) {
if (appliedMigrations.rows.some((m) => m.filename === migration)) {
summary.push(`🏃 ${migration} already applied`)
continue
}
try {
const migrationSql = readFileSync(`${migrationsPath}/${migration}`, 'utf8')
if (hasTransactionBlock(migrationSql)) {
throw new Error(
`Migration ${migration} contains a transaction block. Migrations run in transactions, so you don't need to include them in the migration file.`
)
}
await sql.raw(migrationSql).execute(tx)
await sql`INSERT INTO migrations.applied_migrations (filename) VALUES (${migration})`.execute(
tx
)
appliedNewMigration = true
summary.push(`✅ ${migration} applied`)
} catch (e) {
summary.push(`❌ ${migration} failed`)
throw e
}
}
// Notify Zero once after all DDL has run (see notifyZeroOfSchemaChange).
if (appliedNewMigration) {
await sql.raw(notifyZeroOfSchemaChange).execute(tx)
}
if (dryRun) {
throw DRY_RUN_ROLLBACK
}
})
await db.destroy()
}
async function run() {
try {
await waitForPostgres()
} catch (e) {
console.error(e)
process.exit(1)
}
const summary: string[] = []
try {
await migrate(summary)
console.log(summary.join('\n'))
// need to do this to close the db connection
if (shouldSignalSuccess) {
const s = createServer((_, res) => {
res.end('ok')
})
s.listen(7654)
} else {
process.exit(0)
}
} catch (e) {
if (e === DRY_RUN_ROLLBACK) {
console.log(summary.join('\n'))
console.log('🧹 Rolling back dry run...')
process.exit(0)
}
console.error(e)
console.error(summary.join('\n'))
console.error('🧹 Rolling back...')
process.exit(1)
}
}
run()

View File

@@ -0,0 +1,57 @@
import { readdirSync, readFileSync } from 'fs'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import { describe, expect, it } from 'vitest'
import { hasTransactionBlock } from './migrationSql'
// migrate.ts rejects a migration that opens its own transaction, but it only finds out at deploy
// time, against a real database. The integration suites do not cover it either: they apply
// migration files straight through `client.query(sql)`, bypassing the runner, and they skip
// entirely without ZERO_CACHE_TEST_POSTGRES_URL — which is how CI runs them. So a migration
// carrying a BEGIN/COMMIT reached review green.
const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'migrations')
describe('hasTransactionBlock', () => {
it('finds a transaction block', () => {
expect(hasTransactionBlock('BEGIN;\nALTER TABLE foo DROP COLUMN bar;\nCOMMIT;')).toBe(true)
})
// A lowercase or long-form commit is the dangerous one: it ends the runner's transaction
// silently, so a dry run applies everything after it for real.
it.each([
'commit;',
'COMMIT WORK;',
'COMMIT TRANSACTION;',
'ROLLBACK;',
'START TRANSACTION;',
'BEGIN TRANSACTION;',
'END TRANSACTION;',
'begin ;',
])('finds %s', (statement) => {
expect(hasTransactionBlock(`ALTER TABLE foo DROP COLUMN bar;\n${statement}`)).toBe(true)
})
it('does not mistake a DO block or a plpgsql END for one', () => {
expect(hasTransactionBlock(`DO $$\nBEGIN\n RAISE NOTICE 'x';\nEND $$;`)).toBe(false)
expect(hasTransactionBlock(`IF x THEN\n y;\nEND IF;\nEND;`)).toBe(false)
})
// The reason the check is punctuation-sensitive rather than word-based: every plpgsql function
// body opens with a bare BEGIN, so a word-based check would reject most trigger migrations.
it('does not mistake a plpgsql function body for one', () => {
expect(
hasTransactionBlock(
`CREATE OR REPLACE FUNCTION f() RETURNS trigger AS $$\nBEGIN\n RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;`
)
).toBe(false)
})
})
describe('the migrations checked into this package', () => {
const filenames = readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql'))
it.each(filenames)('%s opens no transaction of its own', (filename) => {
const sql = readFileSync(join(MIGRATIONS_DIR, filename), 'utf8')
expect(hasTransactionBlock(sql)).toBe(false)
})
})

View File

@@ -0,0 +1,18 @@
/**
* The runner applies every migration inside a transaction it opens itself, so a migration that
* opens its own leaves the runner's transaction in a state it did not expect. migrate.ts refuses
* one rather than trying to reconcile that.
*
* Only a statement counts: BEGIN, START TRANSACTION, COMMIT, ROLLBACK or END TRANSACTION, with or
* without WORK/TRANSACTION, in any case, followed by a semicolon. plpgsql function bodies open with
* a bare `BEGIN` and no semicolon, and a migration defining a trigger function is full of them.
*
* A miss is worse than a false positive. The runner sends each file with the simple query
* protocol, so a stray `commit;` ends the runner's transaction mid-file and everything after it,
* `--dry-run` included, applies for real.
*/
export function hasTransactionBlock(migrationSql: string): boolean {
return /\b(begin|start\s+transaction|commit|rollback|end\s+transaction)(\s+(work|transaction))?\s*;/i.test(
migrationSql
)
}

View File

@@ -0,0 +1,72 @@
CREATE TABLE "user" (
"id" VARCHAR PRIMARY KEY,
"name" VARCHAR NOT NULL,
"email" VARCHAR NOT NULL,
"avatar" VARCHAR NOT NULL,
"color" VARCHAR NOT NULL,
"exportFormat" VARCHAR NOT NULL,
"exportTheme" VARCHAR NOT NULL,
"exportBackground" BOOLEAN NOT NULL,
"exportPadding" BOOLEAN NOT NULL,
"createdAt" BIGINT NOT NULL,
"updatedAt" BIGINT NOT NULL,
"flags" VARCHAR NOT NULL,
"locale" VARCHAR,
"animationSpeed" BIGINT,
"edgeScrollSpeed" BIGINT,
"colorScheme" VARCHAR,
"isSnapMode" BOOLEAN,
"isWrapMode" BOOLEAN,
"isDynamicSizeMode" BOOLEAN,
"isPasteAtCursorMode" BOOLEAN
);
CREATE TABLE "file" (
"id" VARCHAR PRIMARY KEY,
"name" VARCHAR NOT NULL,
"ownerId" VARCHAR NOT NULL,
"thumbnail" VARCHAR NOT NULL,
"shared" BOOLEAN NOT NULL,
"sharedLinkType" VARCHAR NOT NULL,
"published" BOOLEAN NOT NULL,
"lastPublished" BIGINT NOT NULL,
"publishedSlug" VARCHAR NOT NULL,
"createdAt" BIGINT NOT NULL,
"updatedAt" BIGINT NOT NULL,
"isEmpty" BOOLEAN NOT NULL,
FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON DELETE CASCADE
);
CREATE TABLE "file_state" (
"userId" VARCHAR NOT NULL,
"fileId" VARCHAR NOT NULL,
"firstVisitAt" BIGINT,
"lastEditAt" BIGINT,
"lastSessionState" VARCHAR,
"lastVisitAt" BIGINT,
PRIMARY KEY ("userId", "fileId"),
FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE,
FOREIGN KEY ("fileId") REFERENCES "file" ("id") ON DELETE CASCADE
);
CREATE TABLE "user_mutation_number" (
"userId" VARCHAR NOT NULL PRIMARY KEY,
"mutationNumber" BIGINT NOT NULL
);
CREATE PUBLICATION alltables FOR ALL TABLES;
CREATE OR REPLACE FUNCTION delete_file_states() RETURNS TRIGGER AS $$
BEGIN
IF OLD.shared = TRUE AND NEW.shared = FALSE THEN
DELETE FROM file_state WHERE "fileId" = OLD.id AND OLD."ownerId" != "userId";
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Step 2: Create the trigger
CREATE TRIGGER file_shared_update
AFTER UPDATE OF shared ON file
FOR EACH ROW
EXECUTE FUNCTION delete_file_states();

View File

@@ -0,0 +1,4 @@
CREATE TABLE "replicator_boot_id" (
"replicatorId" VARCHAR NOT NULL PRIMARY KEY,
"bootId" VARCHAR NOT NULL
);

View File

@@ -0,0 +1,9 @@
CREATE TABLE "user_boot_id" (
"userId" VARCHAR NOT NULL PRIMARY KEY,
"bootId" VARCHAR NOT NULL
);
-- drop the replicator_boot table later
CREATE INDEX file_owner_index ON public.file ("ownerId");
CREATE INDEX file_shared_index ON public.file ("shared");

View File

@@ -0,0 +1,4 @@
-- Purpose: Make the publishedSlug column unique in the file table.
ALTER TABLE public.file
ADD CONSTRAINT unique_publishedSlug UNIQUE ("publishedSlug");

View File

@@ -0,0 +1,38 @@
-- Purpose: Add a column to the file_state table that indicates whether the user is the owner of the file.
ALTER TABLE file_state
ADD COLUMN "isFileOwner" BOOLEAN;
-- To make sure it is always up to date, we add two triggers:
-- When the file_state table is updated, we want to check if the user is the owner of the file
CREATE OR REPLACE FUNCTION update_is_file_owner() RETURNS TRIGGER AS $$
BEGIN
NEW."isFileOwner" := (NEW."userId" = (SELECT "ownerId" FROM file WHERE file.id = NEW."fileId"));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_is_file_owner
BEFORE INSERT OR UPDATE ON file_state
FOR EACH ROW EXECUTE FUNCTION update_is_file_owner();
-- When the file table is updated, we want to check if the user is the owner of the file
CREATE OR REPLACE FUNCTION update_file_state_on_file_change() RETURNS TRIGGER AS $$
BEGIN
UPDATE file_state
SET "isFileOwner" = (file_state."userId" = NEW."ownerId")
WHERE file_state."fileId" = NEW.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_file_state_on_file_change
AFTER UPDATE ON file
FOR EACH ROW EXECUTE FUNCTION update_file_state_on_file_change();
-- popluating the isFileOwner column with no-op update
UPDATE file_state
SET "userId" = file_state."userId";

View File

@@ -0,0 +1,18 @@
-- Purpose: Add a trigger to update the updatedAt column in the file table.
CREATE OR REPLACE FUNCTION update_file_updated_at()
RETURNS TRIGGER AS $$
BEGIN
-- updatedAt is a unix timestamp in milliseconds
NEW."updatedAt" := CAST(EXTRACT(EPOCH FROM now()) * 1000 AS BIGINT);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- When a file is inserted or updated, update the updatedAt column in the file table
CREATE TRIGGER update_file_updatedAt
BEFORE UPDATE ON "file"
FOR EACH ROW
WHEN (OLD.* IS DISTINCT FROM NEW.*)
EXECUTE FUNCTION update_file_updated_at();

View File

@@ -0,0 +1,4 @@
-- Purpose: Add a column to the file table that indicates whether the file has been soft deleted.
ALTER TABLE public.file
ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,52 @@
-- Purpose: update the file table to include the owner's name and avatar.
ALTER TABLE "file"
ADD COLUMN "ownerName" VARCHAR DEFAULT '' NOT NULL;
ALTER TABLE "file"
ADD COLUMN "ownerAvatar" VARCHAR DEFAULT '' NOT NULL;
UPDATE "file"
SET "ownerName" = u."name",
"ownerAvatar" = u."avatar"
FROM public."user" u
WHERE u."id" = "file"."ownerId";
-- When the user's name or avatar is updated, update the ownerName and ownerAvatar columns in the file table
CREATE OR REPLACE FUNCTION update_file_owner_details() RETURNS TRIGGER AS $$
BEGIN
UPDATE "file"
SET "ownerName" = NEW.name,
"ownerAvatar" = NEW.avatar
WHERE "ownerId" = NEW.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- When a file is inserted or updated, update the ownerName and ownerAvatar columns in the file table
CREATE OR REPLACE FUNCTION set_file_owner_details() RETURNS TRIGGER AS $$
BEGIN
UPDATE "file"
SET "ownerName" = u."name",
"ownerAvatar" = u."avatar"
FROM public."user" u
WHERE u."id" = NEW."ownerId" AND "file"."id" = NEW."id";
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- When the user's name or avatar is updated, update the ownerName and ownerAvatar columns in the file table
CREATE TRIGGER "update_file_owner_details_trigger"
AFTER UPDATE OF "name", "avatar" ON public."user"
FOR EACH ROW
WHEN (OLD."name" IS DISTINCT FROM NEW."name" OR OLD."avatar" IS DISTINCT FROM NEW."avatar")
EXECUTE FUNCTION update_file_owner_details();
-- When a file is inserted or updated, update the ownerName and ownerAvatar columns in the file table
CREATE TRIGGER "set_file_owner_details_trigger"
AFTER INSERT OR UPDATE OF "ownerId" ON "file"
FOR EACH ROW
EXECUTE FUNCTION set_file_owner_details();

View File

@@ -0,0 +1,4 @@
-- Purpose: Add an isPinned column to the file_state table that indicates whether the user has pinned the file.
ALTER TABLE file_state
ADD COLUMN "isPinned" BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,8 @@
-- Purpose: Add an allowAnalyticsCookie column to the user table that indicates whether the user has
-- consented to analytics cookies. 3 possible values:
-- - true: the user has consented to analytics cookies
-- - false: the user has not consented to analytics cookies
-- - null: the user has not yet been asked to consent to analytics cookies
ALTER TABLE "user"
ADD COLUMN "allowAnalyticsCookie" BOOLEAN DEFAULT NULL;

View File

@@ -0,0 +1,8 @@
CREATE TABLE "asset" (
"objectName" VARCHAR PRIMARY KEY,
"fileId" VARCHAR NOT NULL,
"uploadedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "fk_file"
FOREIGN KEY ("fileId")
REFERENCES "file"("id")
);

View File

@@ -0,0 +1,5 @@
-- Used for tracking the creation source of a file. Can be a duplication of an existing file,
-- or slurping of a legacy file (multiplayer, readonly, snapshot).
-- This is only used immediately after creation and should then be set to null.
ALTER TABLE "file"
ADD COLUMN "createSource" VARCHAR;

View File

@@ -0,0 +1,24 @@
CREATE OR REPLACE FUNCTION update_file_state_on_file_change() RETURNS TRIGGER AS $$
BEGIN
IF OLD."ownerId" IS DISTINCT FROM NEW."ownerId" THEN
UPDATE file_state
SET "isFileOwner" = (file_state."userId" = NEW."ownerId")
WHERE file_state."fileId" = NEW.id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION update_is_file_owner() RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
NEW."isFileOwner" := (NEW."userId" = (SELECT "ownerId" FROM file WHERE file.id = NEW."fileId"));
ELSIF TG_OP = 'UPDATE' THEN
IF NEW."userId" IS DISTINCT FROM OLD."userId" THEN
NEW."isFileOwner" := (NEW."userId" = (SELECT "ownerId" FROM file WHERE file.id = NEW."fileId"));
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,17 @@
-- In order to correctly dispatch file delete events, we need to know the owner of the file
-- Prior to this migration we did already store the ownerId on the file table, but it was not part of the primary key
-- so it was not present in the replication events for deletions.
-- This migration adds the ownerId to the primary key of the file table, and recreates the dependent foreign keys
-- which had to be deleted when we dropped the file_pkey constraint. Alas there's no way to alter a primary key constraint in place.
-- To maintain the invariant that the file id is unique, we add a new uniqueness constraint on the id column.
ALTER TABLE "file"
DROP CONSTRAINT "file_pkey" CASCADE,
ADD PRIMARY KEY ("id", "ownerId"),
ADD CONSTRAINT "file_id_unique" UNIQUE ("id");
ALTER TABLE "asset"
ADD CONSTRAINT "asset_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "file" ("id") ON DELETE CASCADE;
ALTER TABLE "file_state"
ADD CONSTRAINT "file_state_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "file" ("id") ON DELETE CASCADE;

View File

@@ -0,0 +1,5 @@
-- In order to correctly clean up resources for deleted files, we need to know the publishSlug of the file.
ALTER TABLE "file"
DROP CONSTRAINT "file_pkey",
ADD PRIMARY KEY ("id", "ownerId", "publishedSlug");

View File

@@ -0,0 +1 @@
ALTER TABLE file REPLICA IDENTITY FULL;

View File

@@ -0,0 +1 @@
CREATE PUBLICATION zero_data FOR TABLE file, file_state, public.user;

View File

@@ -0,0 +1,7 @@
ALTER TABLE "asset"
ADD COLUMN "userId" VARCHAR NULL;
ALTER TABLE "asset"
ADD CONSTRAINT "fk_user"
FOREIGN KEY ("userId")
REFERENCES "user"("id");

View File

@@ -0,0 +1,3 @@
-- need to do this so we get isFileOwner when deleting a file
-- and that lets us property determine if a topic subscription should be removed
ALTER TABLE public."file_state" REPLICA IDENTITY FULL;

View File

@@ -0,0 +1,8 @@
-- Purpose: Add an areKeyboardShortcutsEnabled column to the user table that indicates whether the user
-- wants keyboard shortcuts enabled. 3 possible values:
-- - true: the user wants keyboard shortcuts
-- - false: the user does not want keyboard shortcuts
-- - null: the user has not yet set the preference
ALTER TABLE "user"
ADD COLUMN "areKeyboardShortcutsEnabled" BOOLEAN DEFAULT NULL;

View File

@@ -0,0 +1,8 @@
-- Purpose: Add a showUiLabels column to the user table that indicates whether the user
-- wants UI labels displayed. 3 possible values:
-- - true: the user wants UI labels shown
-- - false: the user does not want UI labels shown
-- - null: the user has not yet set the preference
ALTER TABLE "user"
ADD COLUMN "showUiLabels" BOOLEAN DEFAULT NULL;

View File

@@ -0,0 +1,4 @@
-- Purpose: Renames showUiLabels column to enhancedA11yMode column in the user table
ALTER TABLE "user"
RENAME COLUMN "showUiLabels" TO "enhancedA11yMode";

View File

@@ -0,0 +1,5 @@
ALTER TABLE public."user"
ADD COLUMN "inputMode" VARCHAR DEFAULT NULL;
ALTER TABLE public."user"
ADD CONSTRAINT "inputMode_check" CHECK ("inputMode" IS NULL OR "inputMode" IN ('trackpad', 'mouse'));

View File

@@ -0,0 +1,451 @@
----------------------------------
--- CREATE NEW DATA MODEL BITS ---
----------------------------------
CREATE TABLE "group" (
"id" TEXT PRIMARY KEY,
"name" TEXT NOT NULL,
"inviteSecret" TEXT,
"isDeleted" BOOLEAN NOT NULL DEFAULT FALSE,
"createdAt" BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000,
"updatedAt" BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000
);
CREATE INDEX "group_inviteSecret_idx" ON "group" ("inviteSecret");
CREATE TABLE "group_user" (
"userId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"createdAt" BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000,
"updatedAt" BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000,
"role" TEXT NOT NULL CHECK ("role" IN ('admin', 'owner')),
-- index for the user's own sidebar group ordering
"index" TEXT NOT NULL,
-- 'computed' name and color fields are populated by a trigger to share
-- some of the user's details with the other group members
"userName" TEXT NOT NULL,
"userColor" TEXT NOT NULL DEFAULT '#000000',
PRIMARY KEY ("userId", "groupId"),
FOREIGN KEY ("userId") REFERENCES public."user" ("id") ON DELETE CASCADE,
FOREIGN KEY ("groupId") REFERENCES public."group" ("id") ON DELETE CASCADE
);
CREATE TABLE "group_file" (
"fileId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"createdAt" BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000,
"updatedAt" BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000,
"index" TEXT,
PRIMARY KEY ("fileId", "groupId"),
FOREIGN KEY ("fileId") REFERENCES public."file" ("id") ON DELETE CASCADE,
FOREIGN KEY ("groupId") REFERENCES public."group" ("id") ON DELETE CASCADE
);
-- add owningGroupId column
ALTER TABLE public."file" ADD COLUMN "owningGroupId" TEXT REFERENCES public."group" ("id") ON DELETE CASCADE;
-- When the user's name or color is updated, update the userName and userColor columns in the group_user table
CREATE OR REPLACE FUNCTION update_group_user_details() RETURNS TRIGGER AS $$
BEGIN
UPDATE "group_user"
SET "userName" = NEW.name,
"userColor" = COALESCE(NEW.color, '#000000')
WHERE "userId" = NEW.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- When a group_user is inserted or updated, update the userName and userColor columns
CREATE OR REPLACE FUNCTION set_group_user_details() RETURNS TRIGGER AS $$
BEGIN
UPDATE "group_user"
SET "userName" = u."name",
"userColor" = COALESCE(u."color", '#000000')
FROM public."user" u
WHERE u."id" = NEW."userId" AND "group_user"."userId" = NEW."userId" AND "group_user"."groupId" = NEW."groupId";
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- When the user's name or color is updated, update the userName and userColor columns in the group_user table
CREATE TRIGGER "update_group_user_details_trigger"
AFTER UPDATE OF "name", "color" ON public."user"
FOR EACH ROW
WHEN (OLD."name" IS DISTINCT FROM NEW."name" OR OLD."color" IS DISTINCT FROM NEW."color")
EXECUTE FUNCTION update_group_user_details();
-- When a group_user is inserted or updated, update the userName and userEmail columns
CREATE TRIGGER "set_group_user_details_trigger"
AFTER INSERT OR UPDATE OF "userId" ON "group_user"
FOR EACH ROW
EXECUTE FUNCTION set_group_user_details();
ALTER PUBLICATION zero_data ADD TABLE public."group", public."group_user", public."group_file";
-- Remove ownerId and publishedSlug from file table primary key
ALTER TABLE public."file" DROP CONSTRAINT "file_pkey";
ALTER TABLE public."file" ADD PRIMARY KEY ("id");
ALTER TABLE public."file" ALTER COLUMN "ownerId" DROP NOT NULL;
-- Add constraint to ensure exactly one of ownerId or owningGroupId is set
-- This prevents files from being owned by both a user and a group simultaneously
-- XOR logic: one must be null and one must be non-null
ALTER TABLE public."file" ADD CONSTRAINT "file_owner_xor_check"
CHECK (
(("ownerId" IS NULL) != ("owningGroupId" IS NULL)) -- XOR: one must be null and one must be non-null
);
CREATE INDEX "file_owning_group_id_idx" ON public."file" ("owningGroupId");
-- Create a trigger to update file owner details when group name changes
-- This ensures the denormalized ownerName field stays in sync with group name changes
CREATE OR REPLACE FUNCTION update_file_group_details() RETURNS TRIGGER AS $$
BEGIN
UPDATE "file"
SET "ownerName" = NEW.name,
"ownerAvatar" = ''
WHERE "owningGroupId" = NEW.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create a trigger to set file owner details when file ownership changes
-- This handles both user ownership and group ownership scenarios
CREATE OR REPLACE FUNCTION set_file_owner_details() RETURNS TRIGGER AS $$
BEGIN
-- If the file is owned by a user, set user details
IF NEW."ownerId" IS NOT NULL THEN
UPDATE "file"
SET "ownerName" = u."name",
"ownerAvatar" = u."avatar"
FROM public."user" u
WHERE u."id" = NEW."ownerId" AND "file"."id" = NEW."id";
END IF;
-- If the file is owned by a group, set group details
IF NEW."owningGroupId" IS NOT NULL THEN
UPDATE "file"
SET "ownerName" = g."name",
"ownerAvatar" = ''
FROM public."group" g
WHERE g."id" = NEW."owningGroupId" AND "file"."id" = NEW."id";
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create a trigger to update file owner details when group name is updated
CREATE TRIGGER "update_file_group_details_trigger"
AFTER UPDATE OF "name" ON public."group"
FOR EACH ROW
WHEN (OLD."name" IS DISTINCT FROM NEW."name")
EXECUTE FUNCTION update_file_group_details();
-- Create a trigger to set file owner details when file ownership changes
CREATE OR REPLACE TRIGGER "set_file_owner_details_trigger"
AFTER INSERT OR UPDATE OF "ownerId", "owningGroupId" ON "file"
FOR EACH ROW
EXECUTE FUNCTION set_file_owner_details();
-- Create a function to clean up related records when a file is marked as deleted
CREATE OR REPLACE FUNCTION cleanup_deleted_file() RETURNS TRIGGER AS $$
BEGIN
-- Delete any file states for this file
DELETE FROM "file_state"
WHERE "fileId" = NEW."id";
-- Delete any group file associations for this file
DELETE FROM "group_file"
WHERE "fileId" = NEW."id";
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create a trigger to clean up related records when a file is marked as deleted
CREATE TRIGGER "cleanup_deleted_file_trigger"
AFTER UPDATE OF "isDeleted" ON public."file"
FOR EACH ROW
WHEN (OLD."isDeleted" = false AND NEW."isDeleted" = true)
EXECUTE FUNCTION cleanup_deleted_file();
-- Let's delete all the dangling file_state records (there should only be a handful, I already
-- deleted the rows manually in prod)
WITH dangling_file_states AS (
SELECT fs."fileId", fs."userId"
FROM public."file" f INNER JOIN public."file_state" fs ON f."id" = fs."fileId"
WHERE f."isDeleted" = true
)
DELETE FROM public."file_state"
WHERE ("fileId", "userId") IN (SELECT "fileId", "userId" FROM dangling_file_states);
-- Create a function to clean up group associations when a group is marked as deleted
CREATE OR REPLACE FUNCTION cleanup_deleted_group() RETURNS TRIGGER AS $$
BEGIN
-- Delete all group_user associations for this group
DELETE FROM "group_user"
WHERE "groupId" = NEW."id";
-- Delete all group_file associations for this group
DELETE FROM "group_file"
WHERE "groupId" = NEW."id";
-- Mark all files owned by this group as deleted
UPDATE "file"
SET "isDeleted" = true
WHERE "owningGroupId" = NEW."id";
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create a trigger to clean up group associations when a group is marked as deleted
CREATE TRIGGER "cleanup_deleted_group_trigger"
AFTER UPDATE OF "isDeleted" ON public."group"
FOR EACH ROW
WHEN (OLD."isDeleted" = false AND NEW."isDeleted" = true)
EXECUTE FUNCTION cleanup_deleted_group();
-- Create a function to clean up file states when a user leaves a group
-- This removes access to non-shared files that are owned by the group the user just left
CREATE OR REPLACE FUNCTION cleanup_group_user_file_states() RETURNS TRIGGER AS $$
BEGIN
-- Delete file_state records for files that are:
-- 1. Owned by the group the user just left (OLD.groupId)
-- 2. Not shared (so the user no longer has access)
DELETE FROM "file_state"
WHERE "userId" = OLD."userId"
AND "fileId" IN (
SELECT "id" FROM "file"
WHERE "owningGroupId" = OLD."groupId"
AND "shared" = false
);
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
-- Create a trigger to clean up file states when a user leaves a group
CREATE TRIGGER "cleanup_group_user_file_states_trigger"
AFTER DELETE ON public."group_user"
FOR EACH ROW
EXECUTE FUNCTION cleanup_group_user_file_states();
-- Create a function to update the updatedAt timestamp for group tables
CREATE OR REPLACE FUNCTION update_group_timestamp() RETURNS TRIGGER AS $$
BEGIN
NEW."updatedAt" = EXTRACT(EPOCH FROM NOW()) * 1000;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create a function to update the updatedAt timestamp for group_user table
CREATE OR REPLACE FUNCTION update_group_user_timestamp() RETURNS TRIGGER AS $$
BEGIN
NEW."updatedAt" = EXTRACT(EPOCH FROM NOW()) * 1000;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create a function to update the updatedAt timestamp for group_file table
CREATE OR REPLACE FUNCTION update_group_file_timestamp() RETURNS TRIGGER AS $$
BEGIN
NEW."updatedAt" = EXTRACT(EPOCH FROM NOW()) * 1000;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create triggers to automatically update timestamps
CREATE TRIGGER "update_group_timestamp_trigger"
BEFORE UPDATE ON public."group"
FOR EACH ROW
EXECUTE FUNCTION update_group_timestamp();
CREATE TRIGGER "update_group_user_timestamp_trigger"
BEFORE UPDATE ON public."group_user"
FOR EACH ROW
EXECUTE FUNCTION update_group_user_timestamp();
CREATE TRIGGER "update_group_file_timestamp_trigger"
BEFORE UPDATE ON public."group_file"
FOR EACH ROW
EXECUTE FUNCTION update_group_file_timestamp();
-- Create a function to sync home group name with user name on update
-- When a user's name is updated, update the corresponding home group's name
CREATE OR REPLACE FUNCTION update_home_group_name() RETURNS TRIGGER AS $$
BEGIN
-- Update the group name where the group id matches the user id (home group)
UPDATE "group"
SET "name" = NEW.name
WHERE "id" = NEW.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create a trigger to sync home group name when user name is updated
CREATE TRIGGER "update_home_group_name_trigger"
AFTER UPDATE OF "name" ON public."user"
FOR EACH ROW
WHEN (OLD."name" IS DISTINCT FROM NEW."name")
EXECUTE FUNCTION update_home_group_name();
-- Create a function to initialize group name from user name on group creation
-- When a group is created with an id matching a user id, set the group name to the user's name
CREATE OR REPLACE FUNCTION initialize_home_group_name() RETURNS TRIGGER AS $$
BEGIN
-- If there's a user with the same id as the group, update the group name to match
UPDATE "group"
SET "name" = u."name"
FROM public."user" u
WHERE u."id" = NEW."id" AND "group"."id" = NEW."id";
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create a trigger to initialize group name from user name on group creation
CREATE TRIGGER "initialize_home_group_name_trigger"
AFTER INSERT ON public."group"
FOR EACH ROW
EXECUTE FUNCTION initialize_home_group_name();
-- Create a function to validate group_file associations
CREATE OR REPLACE FUNCTION validate_group_file_association() RETURNS TRIGGER AS $$
BEGIN
-- Check that the file is either shared or owned by the group
-- This prevents linking private files that the group doesn't own
IF NOT EXISTS (
SELECT 1 FROM "file" f
WHERE f."id" = NEW."fileId"
AND (f."shared" = true OR f."owningGroupId" = NEW."groupId")
) THEN
RAISE EXCEPTION 'Cannot link file to group: file must be shared or owned by the group';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create a trigger to validate group_file associations before insertion
CREATE TRIGGER "validate_group_file_association_trigger"
BEFORE INSERT ON public."group_file"
FOR EACH ROW
EXECUTE FUNCTION validate_group_file_association();
-- ownerId becomes nullable so we need to update the file_state updater fns to handle NULLs
CREATE OR REPLACE FUNCTION update_file_state_on_file_change() RETURNS TRIGGER AS $$
BEGIN
IF OLD."ownerId" IS DISTINCT FROM NEW."ownerId" THEN
UPDATE file_state
SET "isFileOwner" = COALESCE(file_state."userId" = NEW."ownerId", FALSE)
WHERE file_state."fileId" = NEW.id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION update_is_file_owner() RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
NEW."isFileOwner" := COALESCE(NEW."userId" = (SELECT "ownerId" FROM file WHERE file.id = NEW."fileId"), FALSE);
ELSIF TG_OP = 'UPDATE' THEN
IF NEW."userId" IS DISTINCT FROM OLD."userId" THEN
NEW."isFileOwner" := COALESCE(NEW."userId" = (SELECT "ownerId" FROM file WHERE file.id = NEW."fileId"), FALSE);
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Migration function to move a user from legacy file_state-based model to group_file-based model
-- Usage: SELECT migrate_user_to_groups('user-id-here');
CREATE OR REPLACE FUNCTION migrate_user_to_groups(target_user_id TEXT, invite_secret TEXT)
RETURNS TABLE (
files_migrated INTEGER,
pinned_files_migrated INTEGER,
flag_added BOOLEAN
) AS $$
DECLARE
v_files_migrated INTEGER := 0;
v_pinned_files_migrated INTEGER := 0;
v_current_flags TEXT;
v_file_record RECORD;
v_now BIGINT := EXTRACT(EPOCH FROM NOW()) * 1000;
v_lock_key BIGINT;
BEGIN
-- Check if user exists
IF NOT EXISTS (SELECT 1 FROM public."user" WHERE id = target_user_id) THEN
RAISE EXCEPTION 'User % does not exist', target_user_id;
END IF;
-- Check if user already has groups flag
SELECT flags INTO v_current_flags FROM public."user" WHERE id = target_user_id;
IF v_current_flags LIKE '%groups_backend%' THEN
RAISE NOTICE 'User % already has groups flag, skipping migration', target_user_id;
RETURN QUERY SELECT 0, 0, FALSE;
RETURN;
END IF;
-- Acquire an advisory lock based on the user ID to prevent mutations during migration
-- Convert user ID string to a bigint for the lock key using hashtext
-- Using xact lock so it's automatically released when transaction ends
v_lock_key := hashtext(target_user_id);
RAISE NOTICE 'Acquiring advisory lock % for user %', v_lock_key, target_user_id;
PERFORM pg_advisory_xact_lock(v_lock_key);
-- Lock acquired, now we can safely migrate
RAISE NOTICE 'Lock acquired, starting migration for user %', target_user_id;
-- Create a home group for the user
INSERT INTO public."group" ("id", "name", "createdAt", "updatedAt", "isDeleted", "inviteSecret")
VALUES (target_user_id, target_user_id, v_now, v_now, FALSE, invite_secret)
ON CONFLICT DO NOTHING;
-- Make the user a member of the home group
INSERT INTO public."group_user" ("userId", "groupId", "createdAt", "updatedAt", "role", "index", "userName", "userColor")
VALUES (target_user_id, target_user_id, v_now, v_now, 'owner', 'a1', '', '')
ON CONFLICT DO NOTHING;
-- For any files owned by the user, change the owningGroupId to the home group and set the ownerId to null
UPDATE public."file"
SET "owningGroupId" = target_user_id,
"ownerId" = NULL
WHERE "ownerId" = target_user_id;
-- For any files that the user has access to, create a group_file entry in the home group
INSERT INTO public."group_file" ("fileId", "groupId", "createdAt", "updatedAt")
SELECT fs."fileId", target_user_id, COALESCE(fs."firstVisitAt", f."createdAt", v_now), COALESCE(fs."lastEditAt", fs."firstVisitAt", f."updatedAt", f."createdAt", v_now)
FROM public."file_state" fs JOIN public."file" f ON fs."fileId" = f."id"
WHERE fs."userId" = target_user_id
ON CONFLICT DO NOTHING;
-- Update indexes for the group_file entries
UPDATE public."group_file" gf
SET "index" = 'a' || fs.i::text
FROM (
SELECT "fileId", ROW_NUMBER() over (ORDER BY COALESCE("lastEditAt", "firstVisitAt", 0) DESC) AS i
FROM public."file_state"
WHERE "userId" = target_user_id
AND "isPinned" = TRUE
) AS fs
WHERE gf."fileId" = fs."fileId" AND gf."groupId" = target_user_id;
-- Add 'groups_backend' flag to user
IF v_current_flags IS NULL OR v_current_flags = '' THEN
UPDATE public."user" SET flags = 'groups_backend' WHERE id = target_user_id;
ELSE
UPDATE public."user" SET flags = v_current_flags || ' groups_backend' WHERE id = target_user_id;
END IF;
RAISE NOTICE 'Successfully migrated user % to groups model. Files: %, Pinned: %',
target_user_id, v_files_migrated, v_pinned_files_migrated;
RETURN QUERY SELECT v_files_migrated, v_pinned_files_migrated, TRUE;
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,15 @@
CREATE TABLE user_fairies (
"userId" VARCHAR PRIMARY KEY,
"fairies" VARCHAR,
CONSTRAINT user_fairies_user_id_fkey FOREIGN KEY ("userId") REFERENCES public."user"("id") ON DELETE CASCADE
);
CREATE TABLE file_fairies (
"fileId" VARCHAR,
"userId" VARCHAR,
"fairyState" VARCHAR,
PRIMARY KEY ("fileId", "userId"),
CONSTRAINT file_fairies_file_id_fkey FOREIGN KEY ("fileId") REFERENCES public."file"("id") ON DELETE CASCADE,
CONSTRAINT file_fairies_user_id_fkey FOREIGN KEY ("userId") REFERENCES public."user"("id") ON DELETE CASCADE
);

View File

@@ -0,0 +1,13 @@
-- Add fairy access control columns to user_fairies table
ALTER TABLE user_fairies
ADD COLUMN "fairyLimit" INTEGER,
ADD COLUMN "fairyAccessExpiresAt" BIGINT;
-- Create fairy invite table
CREATE TABLE fairy_invite (
id VARCHAR PRIMARY KEY,
"fairyLimit" INTEGER NOT NULL,
"maxUses" INTEGER NOT NULL,
"currentUses" INTEGER NOT NULL DEFAULT 0,
"createdAt" BIGINT NOT NULL
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE user_fairies
ADD COLUMN "weeklyUsage" JSONB DEFAULT '{}'::jsonb;

View File

@@ -0,0 +1,13 @@
CREATE TABLE file_fairy_messages (
"id" VARCHAR PRIMARY KEY,
"fileId" VARCHAR NOT NULL,
"userId" VARCHAR NOT NULL,
"message" VARCHAR NOT NULL,
"createdAt" BIGINT NOT NULL,
"updatedAt" BIGINT NOT NULL,
CONSTRAINT file_fairy_messages_file_id_fkey FOREIGN KEY ("fileId") REFERENCES public."file"("id") ON DELETE CASCADE,
CONSTRAINT file_fairy_messages_user_id_fkey FOREIGN KEY ("userId") REFERENCES public."user"("id") ON DELETE CASCADE
);
CREATE INDEX file_fairy_messages_file_user_idx ON file_fairy_messages("fileId", "userId");

View File

@@ -0,0 +1,3 @@
ALTER TABLE fairy_invite
ADD COLUMN description TEXT,
ADD COLUMN "redeemedBy" JSONB DEFAULT '[]'::jsonb;

View File

@@ -0,0 +1,21 @@
CREATE TABLE paddle_transactions (
"eventId" VARCHAR PRIMARY KEY,
"transactionId" VARCHAR NOT NULL,
"eventType" VARCHAR NOT NULL,
"status" VARCHAR NOT NULL,
"userId" VARCHAR,
"processed" BOOLEAN NOT NULL DEFAULT FALSE,
"processedAt" BIGINT,
"processingError" TEXT,
"eventData" JSONB NOT NULL,
"occurredAt" BIGINT NOT NULL,
"receivedAt" BIGINT NOT NULL,
"updatedAt" BIGINT NOT NULL
);
CREATE INDEX paddle_transactions_processing_idx ON paddle_transactions("transactionId", "eventType", "processed") WHERE "eventType" = 'transaction.completed';
CREATE INDEX paddle_transactions_user_idx ON paddle_transactions("userId", "occurredAt") WHERE "userId" IS NOT NULL;
CREATE INDEX paddle_transactions_transaction_id_idx ON paddle_transactions("transactionId", "occurredAt");

View File

@@ -0,0 +1,2 @@
ALTER TABLE user_fairies
ADD COLUMN "weeklyLimit" INTEGER DEFAULT 25;

View File

@@ -0,0 +1,5 @@
ALTER TABLE public."user"
ADD COLUMN "isZoomDirectionInverted" BOOLEAN DEFAULT NULL;
ALTER TABLE public."user"
ADD CONSTRAINT "isZoomDirectionInverted_check" CHECK ("isZoomDirectionInverted" IS NULL OR "isZoomDirectionInverted" IN (TRUE, FALSE));

View File

@@ -0,0 +1,2 @@
CREATE INDEX "group_user_group_id_idx" ON public."group_user" ("groupId");
CREATE INDEX "group_file_group_id_idx" ON public."group_file" ("groupId");

View File

@@ -0,0 +1,11 @@
-- Enable Zero's manual DDL detection so it trusts the migration runner's
-- update_schemas() calls instead of event triggers (Supabase doesn't fire them
-- for ALTER PUBLICATION). Guarded because zero_0 doesn't exist until Zero boots,
-- which happens after migrations on a fresh database.
-- https://zero.rocicorp.dev/docs/connecting-to-postgres#schema-change-hooks
DO $$
BEGIN
IF to_regclass('zero_0."shardConfig"') IS NOT NULL THEN
UPDATE zero_0."shardConfig" SET "ddlDetection" = true;
END IF;
END $$;

View File

@@ -0,0 +1,13 @@
-- Rename the non-owner group role from 'admin' to 'member'.
-- The 'admin' label was misleading: the role confers no admin-level
-- capabilities, it's a standard group member.
-- Drop the existing CHECK constraint. It was created as an inline column
-- constraint in 023_groups.sql, so Postgres auto-named it "group_user_role_check".
ALTER TABLE public."group_user" DROP CONSTRAINT IF EXISTS "group_user_role_check";
-- Rewrite existing rows to the new role value.
UPDATE public."group_user" SET "role" = 'member' WHERE "role" = 'admin';
-- Re-add the CHECK constraint with the new allowed values.
ALTER TABLE public."group_user" ADD CONSTRAINT "group_user_role_check" CHECK ("role" IN ('member', 'owner'));

View File

@@ -0,0 +1,58 @@
-- Fix guest cleanup when a group-owned file is unshared.
--
-- The original delete_file_states trigger (000_seed.sql) keys on
-- `OLD."ownerId" != "userId"` to decide which file_states are "guests". The groups
-- backend (023_groups.sql) introduced group-owned files where `ownerId` is NULL
-- (see the ownerId XOR owningGroupId constraint). For those files
-- `NULL != "userId"` evaluates to NULL, so the DELETE matched no rows and guest
-- file_states lingered after unsharing — leaving the now-private file syncing to
-- ex-guests and showing in their recent files.
--
-- Visiting a shared file also links it into the visitor's home group via a
-- group_file row (home group id == user id by convention), which is what puts
-- the file in their recent files. Nothing cleaned those links up on unshare
-- either — so the file's name kept showing in an ex-guest's recent files even
-- once their file_state and access were gone.
--
-- This replaces the function so it cleans up both records for both ownership
-- models:
-- * file_state: delete states for anyone who isn't the legacy owner
-- (NULL-safe) or a current member of the owning group;
-- * group_file: delete the file's links except the owning group's own row
-- and home-group links of users who keep access (the legacy owner /
-- owning-group members).
-- The trigger binding (file_shared_update) is unchanged.
CREATE OR REPLACE FUNCTION delete_file_states() RETURNS TRIGGER AS $$
BEGIN
IF OLD.shared = TRUE AND NEW.shared = FALSE THEN
DELETE FROM file_state fs
WHERE fs."fileId" = OLD.id
-- not the legacy owner (IS DISTINCT FROM is NULL-safe, so a NULL ownerId
-- never accidentally protects a guest the way `!=` did)
AND OLD."ownerId" IS DISTINCT FROM fs."userId"
-- not a current member of the owning group (no-op for legacy files, where
-- owningGroupId is NULL and the subquery returns nothing)
AND NOT EXISTS (
SELECT 1 FROM group_user gu
WHERE gu."groupId" = OLD."owningGroupId"
AND gu."userId" = fs."userId"
);
DELETE FROM group_file gf
WHERE gf."fileId" = OLD.id
-- never the owning group's own row: that's where the file lives
AND gf."groupId" IS DISTINCT FROM OLD."owningGroupId"
-- not the legacy owner's home-group link (home group id == user id)
AND gf."groupId" IS DISTINCT FROM OLD."ownerId"
-- not a home-group link of a current owning-group member (no-op for
-- legacy files, where owningGroupId is NULL and the subquery returns
-- nothing)
AND NOT EXISTS (
SELECT 1 FROM group_user gu
WHERE gu."groupId" = OLD."owningGroupId"
AND gu."userId" = gf."groupId"
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,14 @@
-- Points at the file whose published snapshot seeds a new workspace's first file (the
-- "welcome template"). Set by an admin action; read by the sync worker when it materializes
-- a file created with createSource 'welcome'. When no row is present (fresh env, before an
-- admin has marked one) the worker falls back to a committed default snapshot.
--
-- Singleton: the boolean primary key only permits id = TRUE, so there is at most one row.
-- Deliberately NOT added to the Zero publication — this is worker-side config, not client
-- sync state, so the client never replicates it.
CREATE TABLE welcome_template (
id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id = TRUE),
"fileId" VARCHAR NOT NULL,
"publishedSlug" VARCHAR NOT NULL,
"updatedAt" BIGINT NOT NULL
);

View File

@@ -0,0 +1,29 @@
-- The home/private workspace (group id == user id) is now renameable like any
-- other workspace. Previously its name was forced to mirror the user's profile
-- name via triggers; that fought renames and meant the workspace had no name of
-- its own. Stop syncing the name and default it to "My workspace".
-- 1. Stop re-syncing the home group name to the user's profile name. This trigger
-- would clobber a user's custom workspace name whenever their profile changed.
DROP TRIGGER IF EXISTS "update_home_group_name_trigger" ON public."user";
DROP FUNCTION IF EXISTS update_home_group_name();
-- 2. Default new home groups to "My workspace" instead of the user's name. Keep the
-- creation trigger (it also covers the legacy migrate-to-groups path) but have
-- it set a fixed default rather than copying the user's name.
CREATE OR REPLACE FUNCTION initialize_home_group_name() RETURNS TRIGGER AS $$
BEGIN
-- Only home groups have an id matching a user id.
UPDATE "group"
SET "name" = 'My workspace'
FROM public."user" u
WHERE u."id" = NEW."id" AND "group"."id" = NEW."id";
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 3. Reset existing home group names to "My workspace". Before this migration,
-- home group names were generated from profile names, so no existing name is
-- an intentional workspace-name choice.
UPDATE "group" SET "name" = 'My workspace' WHERE "id" IN (SELECT "id" FROM public."user");

View File

@@ -0,0 +1,2 @@
ALTER TABLE public."group"
ADD COLUMN "inviteLinkEnabled" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -0,0 +1,65 @@
-- Clean up orphan "link" group_file rows left by the removed drag-to-link feature.
--
-- #9107 shipped handleFileDragOperation with a "file link" concept: dragging a
-- SHARED file you don't own onto a workspace inserted a group_file row into that
-- workspace WITHOUT changing the file's owningGroupId (a "link", not a move). The
-- validate_group_file_association trigger allowed it because the file is shared.
-- #9254 removed that feature and moved to a single-ownership model, but never
-- cleaned up the rows it had already written.
--
-- In the current model a non-home workspace lists a file only when it OWNS it
-- (getWorkspaceFilesSorted: owningGroupId === workspaceId). So each leftover link
-- row is invisible AND — worse — onEnterFile's old guard treated "a group_file row
-- exists in one of my groups" as "the file is already visible to me", so it skipped
-- creating the home guest-link. A member of the workspace the file was linked into
-- therefore saw the file NOWHERE. (The onEnterFile guard is fixed separately to key
-- off ownership; this migration removes the bad data.)
--
-- An orphan link row is a group_file row whose group neither owns the file nor is a
-- user's home group (home group id === user id). We must NOT touch:
-- * the owning row (groupId === owningGroupId), or
-- * home guest rows (groupId is some user's id).
-- 1) Re-link affected files into the home of users who could reach them ONLY through
-- an orphan row: members of the orphan's workspace who visited the file as a guest
-- and have no home link yet. Mirrors the fixed onEnterFile so the file reappears in
-- their sidebar without needing to re-open it. Runs before the delete so the orphan
-- rows are still present to derive the affected set. Idempotent via ON CONFLICT.
INSERT INTO public."group_file" ("fileId", "groupId", "createdAt", "updatedAt", "index")
SELECT DISTINCT
fs."fileId",
fs."userId",
(EXTRACT(EPOCH FROM NOW()) * 1000)::bigint,
(EXTRACT(EPOCH FROM NOW()) * 1000)::bigint,
NULL
FROM public."group_file" orphan
JOIN public."file" f ON f."id" = orphan."fileId"
JOIN public."group_user" gu ON gu."groupId" = orphan."groupId"
JOIN public."file_state" fs ON fs."fileId" = orphan."fileId" AND fs."userId" = gu."userId"
WHERE
-- orphan condition: the row's group does not own the file...
orphan."groupId" IS DISTINCT FROM f."owningGroupId"
-- ...and is not a user's home group
AND NOT EXISTS (SELECT 1 FROM public."user" ou WHERE ou."id" = orphan."groupId")
-- only surface files the user can actually still access
AND f."shared" = true
AND f."isDeleted" = false
AND fs."isFileOwner" = false
-- skip users who own the file via a workspace they belong to (already visible there)
AND NOT EXISTS (
SELECT 1 FROM public."group_user" owns
WHERE owns."userId" = fs."userId" AND owns."groupId" = f."owningGroupId"
)
-- skip users who already have a home link for this file
AND NOT EXISTS (
SELECT 1 FROM public."group_file" home
WHERE home."fileId" = fs."fileId" AND home."groupId" = fs."userId"
)
ON CONFLICT ("fileId", "groupId") DO NOTHING;
-- 2) Delete the orphan link rows.
DELETE FROM public."group_file" gf
USING public."file" f
WHERE gf."fileId" = f."id"
AND gf."groupId" IS DISTINCT FROM f."owningGroupId"
AND NOT EXISTS (SELECT 1 FROM public."user" u WHERE u."id" = gf."groupId");

View File

@@ -0,0 +1,37 @@
-- Tighten validate_group_file_association to match the single-ownership model.
--
-- The original trigger (023_groups.sql) allowed a group_file row whenever the file
-- was shared OR owned by the group. That "shared" allowance is what let the removed
-- drag-to-link feature (#9107, removed in #9254) link a shared file into ANY
-- workspace without changing owningGroupId — the orphan rows cleaned up in migration
-- 038. Now that only two kinds of group_file rows are legitimate, tighten the check
-- so the database rejects a re-appearance of those cross-workspace links:
--
-- 1. The owning row: group_file.groupId === file.owningGroupId.
-- 2. A home guest link: group_file.groupId is a user's home group (a user exists
-- with that id) and the file is shared, i.e. the guest can actually access it.
--
-- A shared file can no longer be linked into a non-home workspace that doesn't own
-- it. All current insert paths (createFile, onEnterFile home link, moveFileToWorkspace)
-- satisfy one of the two allowed cases.
CREATE OR REPLACE FUNCTION validate_group_file_association() RETURNS TRIGGER AS $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM "file" f
WHERE f."id" = NEW."fileId"
AND (
-- the group owns the file
f."owningGroupId" = NEW."groupId"
-- ...or it's a home guest link for a shared file
OR (
f."shared" = true
AND EXISTS (SELECT 1 FROM "user" u WHERE u."id" = NEW."groupId")
)
)
) THEN
RAISE EXCEPTION 'Cannot link file to group: group must own the file, or the file must be shared and linked into a user home group';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,115 @@
-- Comments for app files, written by the file's Durable Object from the room's comment records
-- (see TLComment / TLCommentThread in tlschema). Postgres is the sole durable store for comment
-- records: the columns collectively carry every record field, so the Durable Object can rebuild
-- its room's comment records from rows alone on cold start. `lastChangedClock` preserves each
-- record's sync clock across reloads and guards upserts against no-op replays (an update that
-- doesn't advance the clock is skipped, producing no WAL entry for Zero). `anchor`, `meta`,
-- `resolvedBy`, `editedAt`, and `lastChangedClock` are persistence/rehydration columns not
-- declared in the Zero schema, so they never reach clients.
CREATE TABLE comment_thread (
"id" VARCHAR PRIMARY KEY,
"fileId" VARCHAR NOT NULL,
"pageId" VARCHAR NOT NULL,
-- the thread's TLCommentAnchor (discriminated union) as JSON — canonical, used to rebuild the record
"anchor" JSONB NOT NULL,
-- denormalized from anchor for queries; only set for shape-anchored threads
"shapeId" VARCHAR,
"resolvedAt" BIGINT,
"resolvedBy" VARCHAR,
-- soft-deletion flag (see TLCommentThread.isDeleted): threads are never hard-deleted by
-- clients, so the row (and its comments) stays for recovery while room loads and Zero
-- queries filter it out
"isDeleted" BOOLEAN DEFAULT FALSE NOT NULL,
-- no FK to "user": deleting a user must not cascade-delete threads (and thereby other
-- authors' comments); the comment table's authorId FK handles per-author cleanup
"createdBy" VARCHAR NOT NULL,
"createdAt" BIGINT NOT NULL,
"meta" JSONB NOT NULL,
"lastChangedClock" BIGINT NOT NULL,
CONSTRAINT comment_thread_file_id_fkey FOREIGN KEY ("fileId") REFERENCES public."file"("id") ON DELETE CASCADE,
-- resolvedAt/resolvedBy encode one nullable `resolved` record field, so they must be set or
-- null together; rehydration (rowToThreadRecord) relies on resolvedBy being non-null whenever
-- resolvedAt is
CONSTRAINT comment_thread_resolved_check CHECK (("resolvedAt" IS NULL) = ("resolvedBy" IS NULL))
);
CREATE INDEX comment_thread_file_id_idx ON comment_thread("fileId");
CREATE TABLE comment (
"id" VARCHAR PRIMARY KEY,
"fileId" VARCHAR NOT NULL,
"threadId" VARCHAR NOT NULL,
"pageId" VARCHAR NOT NULL,
"authorId" VARCHAR NOT NULL,
-- author display fields, denormalized from the user table by the triggers below (same
-- pattern as file."ownerName") — joining the user row would sync private fields to readers
"authorName" VARCHAR DEFAULT '' NOT NULL,
"authorColor" VARCHAR DEFAULT '' NOT NULL,
"authorAvatar" VARCHAR DEFAULT '' NOT NULL,
"body" JSONB NOT NULL,
"createdAt" BIGINT NOT NULL,
-- null until first edited (exact record field; updatedAt below is the derived sort key)
"editedAt" BIGINT,
-- soft-deletion flag (see TLComment.isDeleted): same model as the thread's — the row stays
-- for recovery while room loads and Zero queries filter it out
"isDeleted" BOOLEAN DEFAULT FALSE NOT NULL,
"updatedAt" BIGINT NOT NULL,
"meta" JSONB NOT NULL,
"lastChangedClock" BIGINT NOT NULL,
CONSTRAINT comment_file_id_fkey FOREIGN KEY ("fileId") REFERENCES public."file"("id") ON DELETE CASCADE,
CONSTRAINT comment_author_id_fkey FOREIGN KEY ("authorId") REFERENCES public."user"("id") ON DELETE CASCADE,
CONSTRAINT comment_thread_id_fkey FOREIGN KEY ("threadId") REFERENCES public."comment_thread"("id") ON DELETE CASCADE
);
CREATE INDEX comment_file_id_idx ON comment("fileId");
CREATE INDEX comment_thread_id_idx ON comment("threadId");
CREATE INDEX comment_author_id_idx ON comment("authorId");
-- Stamp author details on insert — the Durable Object only knows authorId. BEFORE INSERT
-- keeps it a single write with no extra WAL entry for Zero.
CREATE OR REPLACE FUNCTION set_comment_author_details() RETURNS TRIGGER AS $$
DECLARE
author_name VARCHAR;
author_color VARCHAR;
author_avatar VARCHAR;
BEGIN
SELECT u."name", u."color", u."avatar" INTO author_name, author_color, author_avatar
FROM public."user" u
WHERE u."id" = NEW."authorId";
-- a missing user row keeps the defaults, so the insert still fails its author FK check
-- (see isCommentAuthorFkViolation) rather than erroring here
IF FOUND THEN
NEW."authorName" := author_name;
NEW."authorColor" := author_color;
NEW."authorAvatar" := author_avatar;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "set_comment_author_details_trigger"
BEFORE INSERT OR UPDATE OF "authorId" ON comment
FOR EACH ROW
EXECUTE FUNCTION set_comment_author_details();
-- Propagate user renames, recolors, and avatar changes to their existing comments.
CREATE OR REPLACE FUNCTION update_comment_author_details() RETURNS TRIGGER AS $$
BEGIN
UPDATE comment
SET "authorName" = NEW."name",
"authorColor" = NEW."color",
"authorAvatar" = NEW."avatar"
WHERE "authorId" = NEW."id";
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "update_comment_author_details_trigger"
AFTER UPDATE OF "name", "color", "avatar" ON public."user"
FOR EACH ROW
WHEN (OLD."name" IS DISTINCT FROM NEW."name" OR OLD."color" IS DISTINCT FROM NEW."color" OR OLD."avatar" IS DISTINCT FROM NEW."avatar")
EXECUTE FUNCTION update_comment_author_details();
ALTER PUBLICATION zero_data ADD TABLE public."comment_thread";
ALTER PUBLICATION zero_data ADD TABLE public."comment";

View File

@@ -0,0 +1,20 @@
-- Per-user comment read receipts for the app-level /comments view. Row present = the user has
-- read the comment; marking unread deletes the row. `readAt` is stored so a future "edited
-- comments become unread again" rule (readAt < updatedAt) is a client-only change. Authors'
-- own comments get no row: the client treats authorId === me as implicitly read. Written via
-- Zero custom mutators (comment.markRead / comment.markUnread), unlike comment/comment_thread
-- which are written by the file's Durable Object.
CREATE TABLE comment_read (
"userId" VARCHAR NOT NULL,
"commentId" VARCHAR NOT NULL,
"readAt" BIGINT NOT NULL,
PRIMARY KEY ("userId", "commentId"),
CONSTRAINT comment_read_user_id_fkey FOREIGN KEY ("userId") REFERENCES public."user"("id") ON DELETE CASCADE,
CONSTRAINT comment_read_comment_id_fkey FOREIGN KEY ("commentId") REFERENCES public."comment"("id") ON DELETE CASCADE
);
-- covers the cascade path when comments are deleted
CREATE INDEX comment_read_comment_id_idx ON comment_read("commentId");
ALTER PUBLICATION zero_data ADD TABLE public."comment_read";

View File

@@ -0,0 +1,31 @@
-- Per-comment @-mention rows, one per (comment, mentioned user), extracted from the comment's
-- rich-text body by the file's Durable Object when it drains comment records to Postgres (see
-- extractMentionIds / drainCommentOutbox in sync-worker). Mentions live inside the body JSON as
-- TipTap mention nodes, which ZQL cannot reach, so this table is what lets the app-level
-- notifications query filter "comments that mention me" server-side. Server-written only, like
-- comment/comment_thread; rows are reconciled on every comment upsert and cascade away with
-- their comment.
CREATE TABLE comment_mention (
"commentId" VARCHAR NOT NULL,
"userId" VARCHAR NOT NULL,
PRIMARY KEY ("commentId", "userId"),
CONSTRAINT comment_mention_comment_id_fkey FOREIGN KEY ("commentId") REFERENCES public."comment"("id") ON DELETE CASCADE,
-- deleting a mentioned user only removes their mention rows, never the comment itself
CONSTRAINT comment_mention_user_id_fkey FOREIGN KEY ("userId") REFERENCES public."user"("id") ON DELETE CASCADE
);
-- covers the "comments that mention this user" lookup and the user-delete cascade path
CREATE INDEX comment_mention_user_id_idx ON comment_mention("userId");
-- Backfill from existing comment bodies: `$.**` recursively finds mention nodes wherever they
-- nest; DISTINCT collapses the multiple paths lax mode can reach a node through (and repeat
-- mentions); the user join drops ids that aren't users (deleted accounts, malformed ids) —
-- mirroring the FK-violation skip in the Durable Object's drain.
INSERT INTO comment_mention ("commentId", "userId")
SELECT DISTINCT c."id", u."id"
FROM comment c
CROSS JOIN LATERAL jsonb_path_query(c."body", 'lax $.** ? (@.type == "mention").attrs.id') AS mention(id)
JOIN public."user" u ON u."id" = (mention.id #>> '{}');
ALTER PUBLICATION zero_data ADD TABLE public."comment_mention";

View File

@@ -0,0 +1,43 @@
-- Emoji reactions on comments, written by the file's Durable Object from the room's
-- comment-reaction records (see TLCommentReaction in tlschema). Same contract as comment and
-- comment_thread: Postgres is the sole durable store, the columns carry every record field so the
-- Durable Object can rebuild the records from rows alone on cold start, and `lastChangedClock`
-- preserves each record's sync clock and guards upserts against no-op replays.
--
-- A reaction is its own row rather than a column on comment because it is one user's data about
-- someone else's comment — the same reason comment_read is its own table. It keeps every write
-- scoped to one user's own record, so concurrent reactions from different people can't overwrite
-- each other, and a user can never write a reaction attributed to anyone else.
--
-- `meta` and `lastChangedClock` are persistence/rehydration columns not declared in the Zero
-- schema, so they never reach clients.
CREATE TABLE comment_reaction (
"id" VARCHAR PRIMARY KEY,
"fileId" VARCHAR NOT NULL,
"commentId" VARCHAR NOT NULL,
"threadId" VARCHAR NOT NULL,
"pageId" VARCHAR NOT NULL,
"userId" VARCHAR NOT NULL,
"emoji" VARCHAR NOT NULL,
"createdAt" BIGINT NOT NULL,
"meta" JSONB NOT NULL,
"lastChangedClock" BIGINT NOT NULL,
CONSTRAINT comment_reaction_file_id_fkey FOREIGN KEY ("fileId") REFERENCES public."file"("id") ON DELETE CASCADE,
CONSTRAINT comment_reaction_comment_id_fkey FOREIGN KEY ("commentId") REFERENCES public."comment"("id") ON DELETE CASCADE,
CONSTRAINT comment_reaction_thread_id_fkey FOREIGN KEY ("threadId") REFERENCES public."comment_thread"("id") ON DELETE CASCADE,
-- deleting a user removes their reactions, never the comment they reacted to
CONSTRAINT comment_reaction_user_id_fkey FOREIGN KEY ("userId") REFERENCES public."user"("id") ON DELETE CASCADE,
-- one record per (comment, user, emoji): a user may hold multiple reactions on a comment (one
-- per emoji), but not the same emoji twice. The record id is derived from the same triple
-- (createCommentReactionId), so this backstops the invariant at the database.
CONSTRAINT comment_reaction_comment_user_emoji_unique UNIQUE ("commentId", "userId", "emoji")
);
CREATE INDEX comment_reaction_file_id_idx ON comment_reaction("fileId");
CREATE INDEX comment_reaction_comment_id_idx ON comment_reaction("commentId");
CREATE INDEX comment_reaction_thread_id_idx ON comment_reaction("threadId");
-- covers the user-delete cascade path
CREATE INDEX comment_reaction_user_id_idx ON comment_reaction("userId");
ALTER PUBLICATION zero_data ADD TABLE public."comment_reaction";

View File

@@ -0,0 +1,97 @@
-- A shareable, per-file record of who has opened a board, so the comment composer can offer past
-- viewers (not just workspace members) as @-mention targets. This is its OWN table rather than
-- columns on file_state: file_state also carries private per-user data — lastSessionState (a user's
-- camera position, current page, and selected shapes) and visit/edit timestamps — that must never
-- sync to other users. Zero synced queries replicate whole rows, so exposing the viewer list from
-- file_state would leak all of that. Every column here is safe to show to any file collaborator.
-- Identity is denormalized (same reasoning as comment.authorName, migration 040): joining the user
-- row would sync private user fields to every reader.
CREATE TABLE file_visitor (
"userId" VARCHAR NOT NULL,
"fileId" VARCHAR NOT NULL,
"userName" VARCHAR DEFAULT '' NOT NULL,
"userColor" VARCHAR DEFAULT '' NOT NULL,
-- mirrored from file_state for most-recent-first ordering of the roster. NOT NULL (falling back
-- to lastEditAt/firstVisitAt/now at write time) so the fileVisitors query's ORDER BY ... DESC is
-- deterministic — ZQL has no NULLS LAST, and Postgres DESC would sort nulls first.
"lastVisitAt" BIGINT NOT NULL,
PRIMARY KEY ("userId", "fileId")
);
CREATE INDEX file_visitor_file_id_idx ON file_visitor("fileId");
-- Backfill from existing visits. user.name/user.color are NOT NULL (000_seed.sql), so COALESCE only
-- guards the (impossible here) missing-join case and keeps the NOT NULL columns satisfied.
INSERT INTO file_visitor ("userId", "fileId", "userName", "userColor", "lastVisitAt")
SELECT fs."userId", fs."fileId", COALESCE(u."name", ''), COALESCE(u."color", ''),
-- same fallback chain as the client's getFileVisitDate, then "now" for rows with no visit data
COALESCE(fs."lastVisitAt", fs."lastEditAt", fs."firstVisitAt", (EXTRACT(EPOCH FROM now()) * 1000)::BIGINT)
FROM file_state fs
JOIN public."user" u ON u."id" = fs."userId"
ON CONFLICT ("userId", "fileId") DO NOTHING;
-- Mirror a visit (file_state insert, or a lastVisitAt refresh on exit) into file_visitor, stamping
-- the visitor's current name/color. Keyed the same as file_state, so it's a natural upsert.
CREATE OR REPLACE FUNCTION sync_file_visitor_on_file_state() RETURNS TRIGGER AS $$
DECLARE
visitor_name VARCHAR;
visitor_color VARCHAR;
BEGIN
SELECT u."name", u."color" INTO visitor_name, visitor_color
FROM public."user" u
WHERE u."id" = NEW."userId";
INSERT INTO file_visitor ("userId", "fileId", "userName", "userColor", "lastVisitAt")
VALUES (NEW."userId", NEW."fileId", COALESCE(visitor_name, ''), COALESCE(visitor_color, ''),
-- a fresh file_state row (createFile / first onEnterFile) has no lastVisitAt yet; the visit is
-- happening right now, so stamp "now" rather than leave the roster entry unsortable
COALESCE(NEW."lastVisitAt", NEW."lastEditAt", NEW."firstVisitAt", (EXTRACT(EPOCH FROM now()) * 1000)::BIGINT))
ON CONFLICT ("userId", "fileId") DO UPDATE SET
"userName" = COALESCE(visitor_name, file_visitor."userName"),
"userColor" = COALESCE(visitor_color, file_visitor."userColor"),
"lastVisitAt" = COALESCE(NEW."lastVisitAt", file_visitor."lastVisitAt");
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "sync_file_visitor_on_file_state_trigger"
AFTER INSERT OR UPDATE OF "lastVisitAt" ON file_state
FOR EACH ROW
EXECUTE FUNCTION sync_file_visitor_on_file_state();
-- Drop the roster entry when the visit is removed (e.g. un-sharing a file deletes non-owner
-- file_state rows — see 034_fix_unshare_group_file_cleanup.sql), so a viewer who loses access also
-- drops out of the mention roster.
CREATE OR REPLACE FUNCTION delete_file_visitor_on_file_state() RETURNS TRIGGER AS $$
BEGIN
DELETE FROM file_visitor WHERE "userId" = OLD."userId" AND "fileId" = OLD."fileId";
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "delete_file_visitor_on_file_state_trigger"
AFTER DELETE ON file_state
FOR EACH ROW
EXECUTE FUNCTION delete_file_visitor_on_file_state();
-- Propagate user renames and recolors to their existing roster entries, so the roster stays fresh
-- even for a viewer who never re-opens the board (same pattern as update_comment_author_details).
CREATE OR REPLACE FUNCTION update_file_visitor_user_details() RETURNS TRIGGER AS $$
BEGIN
UPDATE file_visitor
SET "userName" = NEW."name",
"userColor" = NEW."color"
WHERE "userId" = NEW."id";
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "update_file_visitor_user_details_trigger"
AFTER UPDATE OF "name", "color" ON public."user"
FOR EACH ROW
WHEN (OLD."name" IS DISTINCT FROM NEW."name" OR OLD."color" IS DISTINCT FROM NEW."color")
EXECUTE FUNCTION update_file_visitor_user_details();
-- New table, so it must be added to the Zero replication publication (unlike file_state, which was
-- already a member).
ALTER PUBLICATION zero_data ADD TABLE file_visitor;

View File

@@ -0,0 +1,52 @@
-- Denormalize the reacting user's display name onto reaction rows, like comment.authorName:
-- joining "user" from the notifications query would expose private user fields to file readers.
ALTER TABLE comment_reaction ADD COLUMN "userName" VARCHAR DEFAULT '' NOT NULL;
-- Stamp the name on insert — the Durable Object only knows userId.
CREATE OR REPLACE FUNCTION set_comment_reaction_user_name() RETURNS TRIGGER AS $$
DECLARE
user_name VARCHAR;
BEGIN
SELECT u."name" INTO user_name
FROM public."user" u
WHERE u."id" = NEW."userId";
-- a missing user row keeps the default, so the insert still fails its user FK check
IF FOUND THEN
NEW."userName" := user_name;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "set_comment_reaction_user_name_trigger"
BEFORE INSERT OR UPDATE OF "userId" ON comment_reaction
FOR EACH ROW
EXECUTE FUNCTION set_comment_reaction_user_name();
-- Propagate user renames to their existing reactions.
CREATE OR REPLACE FUNCTION update_comment_reaction_user_name() RETURNS TRIGGER AS $$
BEGIN
UPDATE comment_reaction
SET "userName" = NEW."name"
WHERE "userId" = NEW."id";
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "update_comment_reaction_user_name_trigger"
AFTER UPDATE OF "name" ON public."user"
FOR EACH ROW
WHEN (OLD."name" IS DISTINCT FROM NEW."name")
EXECUTE FUNCTION update_comment_reaction_user_name();
-- Backfill rows that predate the column.
UPDATE comment_reaction cr
SET "userName" = u."name"
FROM public."user" u
WHERE u."id" = cr."userId";
-- Top-N scans for the app-level feeds: the comments and reactions queries order by createdAt
-- desc with a limit; without these, every query hydration scans and sorts the whole table.
CREATE INDEX comment_created_at_idx ON comment("createdAt" DESC);
CREATE INDEX comment_reaction_created_at_idx ON comment_reaction("createdAt" DESC);

View File

@@ -0,0 +1,24 @@
-- Stamp comment."createdAt" on the server: the notifications reply gate compares comment times
-- with a strict ">", which needs one clock and one total order per thread. Monotonic (max + 1)
-- rather than plain time because the Durable Object drains several comments in one transaction,
-- where now() is frozen and clock_timestamp() ties at millisecond resolution. No race on the
-- SELECT MAX: a thread belongs to one file, and one file is one Durable Object.
--
-- BEFORE INSERT only: the drain retries at-least-once via ON CONFLICT, whose update set does not
-- include "createdAt", so the first successful insert's stamp is permanent.
CREATE OR REPLACE FUNCTION set_comment_created_at() RETURNS TRIGGER AS $$
BEGIN
NEW."createdAt" := GREATEST(
(SELECT COALESCE(MAX("createdAt"), 0) + 1 FROM comment WHERE "threadId" = NEW."threadId"),
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::BIGINT
);
-- keep the derived sort key consistent with the re-stamped creation time
NEW."updatedAt" := GREATEST(NEW."updatedAt", NEW."createdAt");
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "set_comment_created_at_trigger"
BEFORE INSERT ON comment
FOR EACH ROW
EXECUTE FUNCTION set_comment_created_at();

View File

@@ -0,0 +1,53 @@
-- Generic transactional outbox for server-side effects. Rows are written by
-- row-level triggers on source tables (only "file" today) so every write path is
-- captured, including the cleanup_deleted_group_trigger cascade. Drained by
-- TLFileEffectProcessor in sync-worker, which dispatches on "tableName".
-- Future effect sources (e.g. notifications) add a trigger + a consumer handler.
-- NOT added to the zero_data publication: server-only table.
CREATE TABLE public.effect_outbox (
id BIGSERIAL PRIMARY KEY,
"tableName" VARCHAR NOT NULL, -- source table
"entityId" VARCHAR NOT NULL, -- source row id
command VARCHAR NOT NULL, -- 'insert' | 'update' | 'delete'
payload JSONB NOT NULL, -- NEW row (OLD for delete)
"prevPayload" JSONB, -- full OLD row, updates only
attempts INT NOT NULL DEFAULT 0,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE OR REPLACE FUNCTION file_effect_outbox_fn() RETURNS trigger AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO public.effect_outbox ("tableName", "entityId", command, payload)
VALUES ('file', NEW.id, 'insert', to_jsonb(NEW));
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
-- Skip no-op updates: room persists bump "updatedAt" every few seconds per active
-- room; only effect-relevant columns should produce outbox rows.
IF NEW.name IS NOT DISTINCT FROM OLD.name
AND NEW.shared IS NOT DISTINCT FROM OLD.shared
AND NEW."sharedLinkType" IS NOT DISTINCT FROM OLD."sharedLinkType"
AND NEW.published IS NOT DISTINCT FROM OLD.published
AND NEW."lastPublished" IS NOT DISTINCT FROM OLD."lastPublished"
AND NEW."publishedSlug" IS NOT DISTINCT FROM OLD."publishedSlug"
AND NEW."isDeleted" IS NOT DISTINCT FROM OLD."isDeleted"
AND NEW."ownerId" IS NOT DISTINCT FROM OLD."ownerId"
AND NEW."owningGroupId" IS NOT DISTINCT FROM OLD."owningGroupId"
THEN
RETURN NEW;
END IF;
INSERT INTO public.effect_outbox ("tableName", "entityId", command, payload, "prevPayload")
VALUES ('file', NEW.id, 'update', to_jsonb(NEW), to_jsonb(OLD));
RETURN NEW;
ELSE
INSERT INTO public.effect_outbox ("tableName", "entityId", command, payload)
VALUES ('file', OLD.id, 'delete', to_jsonb(OLD));
RETURN OLD;
END IF;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER file_effect_outbox_after_change
AFTER INSERT OR UPDATE OR DELETE ON public.file
FOR EACH ROW EXECUTE FUNCTION file_effect_outbox_fn();

View File

@@ -0,0 +1,4 @@
-- Timed retry backoff for effect_outbox. A failed row schedules its next
-- eligible retry so rapid pokes can't burn all attempts in a burst; the drain
-- filter skips rows until "nextRetryAt" passes and the 30s alarm sweep retries.
ALTER TABLE public.effect_outbox ADD COLUMN "nextRetryAt" TIMESTAMPTZ;

View File

@@ -0,0 +1,11 @@
-- Leftovers of the legacy custom sync engine, removed in #9894. The tables had no
-- readers or writers since then and were never in the zero_data publication.
-- "alltables" was the legacy replicator's FOR ALL TABLES publication; with no
-- subscriber it retains no WAL, but it makes UPDATE/DELETE fail on any future
-- table without a replica identity, so it goes too. Zero only uses zero_data.
-- The tlpr_ replication slot is dropped manually per environment (see #9899).
DROP PUBLICATION IF EXISTS alltables;
DROP TABLE IF EXISTS public.user_mutation_number;
DROP TABLE IF EXISTS public.replicator_boot_id;
DROP TABLE IF EXISTS public.user_boot_id;

View File

@@ -0,0 +1,179 @@
-- Drops the legacy user-owns-file model (issue #10050).
--
-- Every file has lived in a workspace since the groups migration, `file."ownerId"` has been
-- NULL throughout production since then, and #10391 removed the last code that read it. What
-- remains is the schema itself plus the trigger functions that still maintain it.
--
-- Postgres tracks dependencies for indexes, foreign keys, check constraints and a trigger's
-- `UPDATE OF` column list, so those drop or block on their own. It does NOT parse plpgsql
-- bodies: a function still naming a dropped column survives the DDL and fails at runtime on
-- the next write. That is why the function work below is explicit, and comes before the
-- column drops.
--
-- The runner puts the whole migration in one transaction (migrate.ts). A transaction with any
-- DDL on a replicated table resets every view-syncer pipeline and rehydrates connected clients,
-- once per transaction rather than per statement, and Zero is notified once after all of it.
-- Deploy off-peak, and only after #10653 has shipped: a client whose schema still declares
-- these columns is disconnected the moment they drop.
-- Fails before any lock is taken if a file still has no workspace, with a message that says
-- so. Without this the SET NOT NULL below fails with a bare "contains null values".
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM public."file" WHERE "owningGroupId" IS NULL) THEN
RAISE EXCEPTION 'files without an owningGroupId remain; every file must belong to a workspace before the legacy owner columns can be dropped';
END IF;
END $$;
-- Lock order. An app transaction that changes a user's name or avatar holds `user` and then
-- writes `file` through update_file_owner_details_trigger, which is installed until this
-- migration drops it, and every `file_state` writer reads `file` first. The DDL below takes
-- ACCESS EXCLUSIVE on all three, and locks are held to the end of the transaction, so taking
-- them in any other order than `user`, `file`, `file_state` can deadlock with a mutator that
-- is mid-flight when the deploy runs. Statement order is not enough to guarantee that, so the
-- first two are taken here explicitly. `file_state` is not: nothing needs it until after the
-- scan below, and it stays writable until then.
LOCK TABLE public."user", public."file" IN ACCESS EXCLUSIVE MODE;
-- The one statement here that does real work: a full scan of `file`. It comes first so that
-- `user` is held for the scan plus catalog writes rather than for the scan plus everything
-- else, and `file_state` is not held for the scan at all. With the legacy model gone, every
-- file belongs to a workspace.
ALTER TABLE public."file" ALTER COLUMN "owningGroupId" SET NOT NULL;
-- These exist only to maintain the legacy columns.
DROP TRIGGER IF EXISTS trigger_update_file_state_on_file_change ON public."file";
DROP FUNCTION IF EXISTS public.update_file_state_on_file_change();
DROP TRIGGER IF EXISTS trigger_update_is_file_owner ON public."file_state";
DROP FUNCTION IF EXISTS public.update_is_file_owner();
DROP TRIGGER IF EXISTS update_file_owner_details_trigger ON public."user";
DROP FUNCTION IF EXISTS public.update_file_owner_details();
-- The runtime backfill that moved users onto the groups model, and the source of the
-- `owningGroupId`-without-`group_file` orphans repaired ahead of this migration: it set
-- `owningGroupId` for every file a user owned but created links only for files the user had a
-- `file_state` row for, so a file its owner had never opened got the id and no link.
DROP FUNCTION IF EXISTS public.migrate_user_to_groups(text, text);
-- Was two arms, one per ownership model. Only the group arm is reachable now, its
-- `"ownerAvatar" = ''` assignment goes with the column, and its null check goes with the
-- NOT NULL constraint above.
CREATE OR REPLACE FUNCTION public.set_file_owner_details()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
UPDATE "file"
SET "ownerName" = g."name"
FROM public."group" g
WHERE g."id" = NEW."owningGroupId" AND "file"."id" = NEW."id";
RETURN NEW;
END;
$function$;
-- Redefined rather than left alone: its column list still named "ownerId".
CREATE OR REPLACE TRIGGER set_file_owner_details_trigger
AFTER INSERT OR UPDATE OF "owningGroupId" ON public."file"
FOR EACH ROW EXECUTE FUNCTION set_file_owner_details();
CREATE OR REPLACE FUNCTION public.update_file_group_details()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
UPDATE "file"
SET "ownerName" = NEW.name
WHERE "owningGroupId" = NEW.id;
RETURN NEW;
END;
$function$;
-- Both dropped guards were already no-ops: `ownerId` is NULL on every row, and
-- `NULL IS DISTINCT FROM x` is TRUE, so neither ever excluded anything. The `group_user`
-- membership checks are what actually protect an owner's own state on unshare.
CREATE OR REPLACE FUNCTION public.delete_file_states()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
IF OLD.shared = TRUE AND NEW.shared = FALSE THEN
DELETE FROM file_state fs
WHERE fs."fileId" = OLD.id
-- not a current member of the owning group
AND NOT EXISTS (
SELECT 1 FROM group_user gu
WHERE gu."groupId" = OLD."owningGroupId"
AND gu."userId" = fs."userId"
);
DELETE FROM group_file gf
WHERE gf."fileId" = OLD.id
-- never the owning group's own row: that's where the file lives
AND gf."groupId" <> OLD."owningGroupId"
-- not a home-group link of a current owning-group member
AND NOT EXISTS (
SELECT 1 FROM group_user gu
WHERE gu."groupId" = OLD."owningGroupId"
AND gu."userId" = gf."groupId"
);
END IF;
RETURN NEW;
END;
$function$;
-- Only the no-op short-circuit changes: "ownerId" leaves the list of columns whose change is
-- worth an outbox row. Miss this and every file write starts producing effects again.
CREATE OR REPLACE FUNCTION public.file_effect_outbox_fn()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO public.effect_outbox ("tableName", "entityId", command, payload)
VALUES ('file', NEW.id, 'insert', to_jsonb(NEW));
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
-- Skip no-op updates: room persists bump "updatedAt" every few seconds per active
-- room; only effect-relevant columns should produce outbox rows.
IF NEW.name IS NOT DISTINCT FROM OLD.name
AND NEW.shared IS NOT DISTINCT FROM OLD.shared
AND NEW."sharedLinkType" IS NOT DISTINCT FROM OLD."sharedLinkType"
AND NEW.published IS NOT DISTINCT FROM OLD.published
AND NEW."lastPublished" IS NOT DISTINCT FROM OLD."lastPublished"
AND NEW."publishedSlug" IS NOT DISTINCT FROM OLD."publishedSlug"
AND NEW."isDeleted" IS NOT DISTINCT FROM OLD."isDeleted"
AND NEW."owningGroupId" IS NOT DISTINCT FROM OLD."owningGroupId"
THEN
RETURN NEW;
END IF;
INSERT INTO public.effect_outbox ("tableName", "entityId", command, payload, "prevPayload")
VALUES ('file', NEW.id, 'update', to_jsonb(NEW), to_jsonb(OLD));
RETURN NEW;
ELSE
INSERT INTO public.effect_outbox ("tableName", "entityId", command, payload)
VALUES ('file', OLD.id, 'delete', to_jsonb(OLD));
RETURN OLD;
END IF;
END;
$function$;
-- Postgres would drop all three with the column; spelled out so reviewing this does not
-- require knowing that.
DROP INDEX IF EXISTS public.file_owner_index;
ALTER TABLE public."file" DROP CONSTRAINT IF EXISTS "file_owner_xor_check";
ALTER TABLE public."file" DROP CONSTRAINT IF EXISTS "file_ownerId_fkey";
-- `file_state."isPinned"` needs no backfill: the client has read pin state from
-- `group_file."index"` since groups landed, so the ~331 rows still saying `true` have had no
-- effect for months. Copying them into `index` would re-pin boards that are currently
-- unpinned, and `unpinFile` writes the same `index = NULL` those rows would be matched by, so
-- a deliberate unpin is indistinguishable from a never-migrated pin.
ALTER TABLE public."file" DROP COLUMN IF EXISTS "ownerId";
ALTER TABLE public."file" DROP COLUMN IF EXISTS "ownerAvatar";
ALTER TABLE public."file_state" DROP COLUMN IF EXISTS "isFileOwner";
ALTER TABLE public."file_state" DROP COLUMN IF EXISTS "isPinned";

View File

@@ -0,0 +1,4 @@
-- Last failure message for an effect_outbox row, overwritten on every failed attempt.
-- The drain only reports the first and parking attempts to Sentry, so without this a
-- parked row shows "attempts 10" and nothing else; the row carries its own diagnosis.
ALTER TABLE public.effect_outbox ADD COLUMN "lastError" TEXT;

View File

@@ -0,0 +1,31 @@
-- Serves `search_boards` (searchBoards.ts): each arm of its per-workspace merge filters on
-- `"owningGroupId" = :group` and orders by "createdAt" desc, so with both in one index a page is
-- read in index order and stops, instead of top-N sorting the caller's whole in-scope set. Only
-- usable now that the access predicate is a single equality — the `"ownerId" = :caller` arm it used
-- to carry made the planner answer with a BitmapOr, whose output has no order.
--
-- The `file.id` tiebreak in that ORDER BY is deliberately left out: the primary key already makes
-- id unique, and ties on "createdAt" are rare enough that sorting within one is not worth a wider
-- index.
--
-- Built plainly rather than CONCURRENTLY, which would need the runner to leave its transaction. A
-- plain build takes SHARE on "file", so reads continue but every write blocks — board create,
-- rename, delete, and the "updatedAt" bump `005_update_file_trigger.sql` fires on any row change.
-- Measured at production's shape (968k rows, 406MB heap, Postgres 16) that window is 1-2 seconds.
--
-- The runner holds one transaction over every migration in a run, so the SHARE lock is taken here
-- and released at the run's COMMIT, not when the build ends: everything queued after this file
-- extends the window in which writes to "file" block.
--
-- Keep it the only index build in its run, and specifically do not add one on "group_file" here.
-- This transaction would then hold "file" while waiting for "group_file", and `moveFileToWorkspace`
-- takes those two in the opposite order — reproduced on PG16, and the migration is the one Postgres
-- kills, so the deploy fails. A run that locks one of the two cannot be in that cycle at all.
-- IF NOT EXISTS so a deploy still succeeds when the index was created by hand ahead of it — the
-- likely reason being someone building it out of band to avoid the write stall above. Note it
-- matches on *name only*, not definition: an index of this name with different columns or ordering
-- would satisfy this and leave the search unindexed with a green deploy. Check `pg_index` rather
-- than trusting the clause if you ever pre-create it.
CREATE INDEX IF NOT EXISTS "file_owning_group_created_at_idx"
ON public."file" ("owningGroupId", "createdAt" DESC);

View File

@@ -0,0 +1,90 @@
events {
worker_connections 1024;
}
http {
server {
listen 4848;
# envsubst from start.sh replaces FLY_MACHINE_ID only
set $machine_id "${FLY_MACHINE_ID}";
location / {
# Sticky routing for multi-machine Zero view-syncer. Clients store fly_machine_id (which
# Fly machine they last used). Same idea as Fly replay_cache in flyio-view-syncer. Goal:
# keep a session on one box for warm Zero state (see zero.rocicorp.dev self-host sticky sessions).
#
# $machine_id = this container (FLY_MACHINE_ID). The cookie may name another machine.
# Only the proxy_pass branch below sets Set-Cookie; a 307 fly-replay response does not, so
# fixing a bad cookie always ends with a successful proxy response.
#
# Paths (nginx picks the first that applies):
#
# A — No cookie, or cookie already matches $machine_id: do not fly-replay. Proxy to Zero on
# :4849 and set fly_machine_id to this machine (first visit, refresh, or already correct).
#
# B — Cookie names a different machine, first hop (no Fly-Replay-Src), Fly did not already
# signal Fly-Preferred-Instance-Unavailable: return 307 + fly-replay prefer_instance=<cookie>.
# Fly’s edge tries to route the request to that machine if it exists and can take load.
# If that machine is gone, busy (hard_limit), etc., Fly may deliver the request here instead
# and set Fly-Preferred-Instance-Unavailable (see Fly fly-replay docs).
#
# C — Cookie names a different machine but Fly-Replay-Src is present: Fly already replayed
# once (e.g. previous hop asked for the cookie machine and it was unreachable). Do not
# return another 307—proxy here and rewrite the cookie to $machine_id.
#
# D — Cookie names a different machine and Fly-Preferred-Instance-Unavailable is present:
# Fly already decided prefer_instance could not be satisfied for that ID. Do not return
# another 307 (would loop). Proxy here and rewrite the cookie. Header is forgeable in
# theory; low impact (stickiness only); typical browsers hit Fly first.
set $check_replay "";
if ($cookie_fly_machine_id != "") {
set $check_replay "has_cookie";
}
if ($cookie_fly_machine_id != $machine_id) {
set $check_replay "${check_replay}_different";
}
if ($http_fly_replay_src != "") {
set $check_replay "${check_replay}_replayed";
}
set $prefer_instance_unavailable "";
if ($http_fly_preferred_instance_unavailable != "") {
set $prefer_instance_unavailable "1";
}
set $issue_fly_replay "0";
if ($check_replay = "has_cookie_different") {
set $issue_fly_replay "1";
}
if ($prefer_instance_unavailable = "1") {
set $issue_fly_replay "0";
}
if ($issue_fly_replay = "1") {
add_header fly-replay "prefer_instance=$cookie_fly_machine_id" always;
return 307;
}
# Zero (upstream); sticky cookie refreshed below
proxy_pass http://127.0.0.1:4849;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# zero-cache can send large headers
proxy_buffer_size 64k;
proxy_buffers 8 64k;
# Long-lived sync / WebSocket upstream
proxy_read_timeout 86400;
proxy_send_timeout 86400;
# Pin this machine; SameSite=None for cross-origin (e.g. tldraw.com → fly.dev)
add_header Set-Cookie "fly_machine_id=$machine_id; Path=/; Max-Age=3600; SameSite=None; Secure" always;
}
}
}

View File

@@ -0,0 +1,33 @@
{
"name": "@tldraw/zero-cache",
"description": "The production app for tldraw.",
"version": "0.0.0",
"private": true,
"author": {
"name": "tldraw GB Ltd.",
"email": "hello@tldraw.com"
},
"browserslist": [
"defaults"
],
"scripts": {
"migrate": "yarn tsx ./migrate.ts",
"lint": "yarn run -T tsx ../../../internal/scripts/lint.ts",
"test": "yarn run -T vitest --passWithNoTests",
"test-ci": "yarn run -T vitest run --passWithNoTests",
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests"
},
"type": "module",
"dependencies": {
"@rocicorp/zero": "1.9.0",
"kysely": "^0.28.12",
"pg": "^8.16.3"
},
"devDependencies": {
"concurrently": "^9.1.2",
"dotenv": "^16.4.7",
"lazyrepo": "0.0.0-alpha.27",
"tsx": "^4.19.2",
"vitest": "^5.0.0"
}
}

View File

@@ -0,0 +1,232 @@
import { readFileSync } from 'fs'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import pg from 'pg'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
// Focused integration test for the `set_comment_created_at` trigger (migration 046), which
// replaces the authoring client's `createdAt` with a server stamp that is monotonic per thread.
// Client stamps let a thread's history read as post-join replies in the notifications feed; the
// stamp must also be strictly increasing within a thread because the notifications reply gate
// compares with a strict `>`, and the Durable Object drains several comments in one transaction
// where `now()` is frozen and even `clock_timestamp()` can tie at millisecond resolution.
//
// This talks to a real postgres (the trigger is plpgsql, so fakes can't exercise it). It is
// opt-in: set ZERO_CACHE_TEST_POSTGRES_URL (local dev stack:
// postgres://user:password@localhost:6543/postgres) to run it. Without a connection string the
// suite is skipped so CI stays green.
//
// Safety: same model as delete_file_states.test.ts — everything the test owns is
// schema-qualified, and the statements that cannot be qualified (the shipped migration SQL run
// verbatim, and the inserts whose trigger body resolves `comment` via search_path at execution
// time) run inside a transaction with `SET LOCAL search_path`.
const DIRNAME = dirname(fileURLToPath(import.meta.url))
const CONNECTION_STRING = process.env.ZERO_CACHE_TEST_POSTGRES_URL
// The real, shipped trigger. Loaded from the migration file so the test exercises exactly what
// runs in production rather than a hand-copied duplicate.
const MIGRATION_SQL = readFileSync(
join(DIRNAME, 'migrations', '046_stamp_comment_created_at.sql'),
'utf8'
)
const schemaName = `tldraw_test_comment_${process.pid}`
// Only the columns the trigger reads or writes plus the drain's clock guard column. No foreign
// keys: the trigger never touches the referenced tables.
const SCHEMA_SQL = `
DROP TABLE IF EXISTS "${schemaName}"."comment" CASCADE;
CREATE TABLE "${schemaName}"."comment" (
"id" VARCHAR PRIMARY KEY,
"threadId" VARCHAR NOT NULL,
"body" JSONB NOT NULL,
"createdAt" BIGINT NOT NULL,
"editedAt" BIGINT,
"updatedAt" BIGINT NOT NULL,
"lastChangedClock" BIGINT NOT NULL
);
`
const describeMaybe = CONNECTION_STRING ? describe : describe.skip
if (!CONNECTION_STRING) {
// eslint-disable-next-line no-console
console.warn(
'stamp_comment_created_at.test.ts: skipping — the client notifications gate depends on this ' +
'trigger; set ZERO_CACHE_TEST_POSTGRES_URL to verify it against a real postgres.'
)
}
describeMaybe('set_comment_created_at trigger (server-stamped comment timestamps)', () => {
// A single Client, not a Pool: inTestSchema's BEGIN/COMMIT must run on one connection.
let client: pg.Client
// Runs statements inside one transaction with search_path pinned to the test schema, for SQL
// executed verbatim (the shipped migration) and statements whose trigger body resolves the
// unqualified `comment` at execution time.
async function inTestSchema(...statements: string[]) {
await client.query('BEGIN')
try {
await client.query(`SET LOCAL search_path TO "${schemaName}"`)
for (const sql of statements) {
await client.query(sql)
}
await client.query('COMMIT')
} catch (err) {
await client.query('ROLLBACK')
throw err
}
}
beforeAll(async () => {
client = new pg.Client({ connectionString: CONNECTION_STRING })
await client.connect()
await client.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`)
await client.query(`CREATE SCHEMA "${schemaName}"`)
await client.query('BEGIN')
await client.query(`SET LOCAL search_path TO "${schemaName}"`)
const res = await client.query<{ schema: string }>('SELECT current_schema() AS schema')
await client.query('COMMIT')
if (res.rows[0]?.schema !== schemaName) {
throw new Error(
`SET LOCAL search_path did not hold within a transaction (current_schema() is ` +
`"${res.rows[0]?.schema}", expected "${schemaName}"). Refusing to run unqualified ` +
`DDL against this connection.`
)
}
})
afterAll(async () => {
if (!client) return
await client.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`)
await client.end()
})
beforeEach(async () => {
await client.query(SCHEMA_SQL)
await inTestSchema(MIGRATION_SQL)
})
async function serverNowMs(): Promise<number> {
const res = await client.query<{ now: string }>(
`SELECT (EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::BIGINT AS now`
)
return Number(res.rows[0].now)
}
// Mirrors the shape of the drain's upsert in TLFileDurableObject.ts (insertCommentRows) over
// this table's column subset. The parts that matter — and must stay in sync with the drain —
// are the conflict branch excluding "createdAt" (what makes the trigger's stamp permanent
// across at-least-once replays and edits) and the lastChangedClock guard.
async function upsert(
rows: {
id: string
threadId: string
createdAt: number
editedAt?: number | null
body?: string
clock?: number
}[]
) {
const values = rows
.map(
(r) =>
`('${r.id}', '${r.threadId}', '${r.body ?? '{}'}', ${r.createdAt}, ${
r.editedAt ?? 'NULL'
}, ${r.editedAt ?? r.createdAt}, ${r.clock ?? 1})`
)
.join(', ')
await inTestSchema(
`INSERT INTO comment ("id", "threadId", "body", "createdAt", "editedAt", "updatedAt", "lastChangedClock")
VALUES ${values}
ON CONFLICT ("id") DO UPDATE SET
"threadId" = excluded."threadId",
"body" = excluded."body",
"editedAt" = excluded."editedAt",
"updatedAt" = excluded."updatedAt",
"lastChangedClock" = excluded."lastChangedClock"
WHERE "comment"."lastChangedClock" < excluded."lastChangedClock"`
)
}
async function stamps() {
const res = await client.query<{
id: string
createdAt: string
editedAt: string | null
updatedAt: string
}>(`SELECT "id", "createdAt", "editedAt", "updatedAt" FROM "${schemaName}"."comment"`)
return new Map(
res.rows.map((r) => [
r.id,
{
createdAt: Number(r.createdAt),
editedAt: r.editedAt === null ? null : Number(r.editedAt),
updatedAt: Number(r.updatedAt),
},
])
)
}
it('replaces the client stamp with server time and lifts updatedAt, whether the client clock is slow or fast', async () => {
const before = await serverNowMs()
await upsert([
{ id: 'c-slow', threadId: 't1', createdAt: before - 3_600_000 },
{ id: 'c-fast', threadId: 't2', createdAt: before + 3_600_000, editedAt: 2_000 },
])
const after = await serverNowMs()
const rows = await stamps()
for (const id of ['c-slow', 'c-fast']) {
const { createdAt, updatedAt } = rows.get(id)!
expect(createdAt).toBeGreaterThanOrEqual(before)
expect(createdAt).toBeLessThanOrEqual(after)
expect(updatedAt).toBe(createdAt)
}
expect(rows.get('c-fast')!.editedAt).toBe(2_000)
})
it('stamps a multi-row insert strictly increasing per thread, in statement order', async () => {
// The drain writes a batch in one statement, where now() is frozen and clock_timestamp()
// can tie at millisecond resolution. Ties would break the notifications feed's strict
// "after my join" compare.
const before = await serverNowMs()
await upsert([
{ id: 'c1', threadId: 't1', createdAt: 1_000 },
{ id: 'c2', threadId: 't1', createdAt: 1_000 },
{ id: 'c3', threadId: 't1', createdAt: 500 },
{ id: 'd1', threadId: 't2', createdAt: 1_000 },
])
const after = await serverNowMs()
const rows = await stamps()
expect(rows.get('c1')!.createdAt).toBeLessThan(rows.get('c2')!.createdAt)
expect(rows.get('c2')!.createdAt).toBeLessThan(rows.get('c3')!.createdAt)
// each thread chains independently: t2's single row stamps at real time, not after t1's
expect(rows.get('d1')!.createdAt).toBeGreaterThanOrEqual(before)
expect(rows.get('d1')!.createdAt).toBeLessThanOrEqual(after)
})
it('keeps the first stamp through the drain-shaped conflict branch, and keeps updatedAt >= createdAt', async () => {
// The drain retries at-least-once, so a row can arrive again as a conflict on id — with an
// edit here, carrying a client-clock editedAt/updatedAt far behind the server stamp. The
// first stamp must hold. The conflict branch takes excluded."updatedAt" verbatim, which is
// safe only because the excluded tuple went through the BEFORE INSERT trigger and had its
// updatedAt lifted to the (server) attempt stamp — this asserts that coupling.
await upsert([{ id: 'c1', threadId: 't1', createdAt: 1_000, clock: 1 }])
const stamped = (await stamps()).get('c1')!.createdAt
await upsert([
{
id: 'c1',
threadId: 't1',
createdAt: 1_000,
editedAt: 2_000,
body: '{"edited": true}',
clock: 2,
},
])
const row = (await stamps()).get('c1')!
expect(row.createdAt).toBe(stamped)
expect(row.editedAt).toBe(2_000)
expect(row.updatedAt).toBeGreaterThanOrEqual(row.createdAt)
})
})

View File

@@ -0,0 +1,38 @@
#!/bin/sh
set -e
# Substitute FLY_MACHINE_ID into nginx config (only this var, preserve nginx $vars)
envsubst '${FLY_MACHINE_ID}' < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf
# Give every machine a distinct OTel instance identity.
#
# Grafana Cloud's OTLP ingestion only promotes a fixed set of resource
# attributes to Prometheus labels: service.name, service.namespace,
# deployment.environment and service.instance.id. host.name is not among them —
# it lands in target_info instead — so OTEL_NODE_RESOURCE_DETECTORS="...,host,..."
# is not enough to tell two machines apart in a metric query.
#
# Without a distinct instance label every machine writes to the *same* series.
# Prometheus then sees one series being overwritten by several independent
# cumulative counters, reads each downward jump as a counter reset, and rate()
# adds up every apparent increase. The result is a rate inflated by orders of
# magnitude that climbs steadily since the last deploy and drops to zero when
# the machines restart together. Ratios and histogram quantiles survive it
# (the inflation is common-mode and cancels); every absolute counter rate does
# not.
#
# This must be set here rather than in the fly.toml [env] block, because
# FLY_MACHINE_ID only exists at runtime. supervisord passes its own environment
# through to child programs, so exporting before exec is enough for zero-cache
# to pick it up.
if [ -n "$FLY_MACHINE_ID" ]; then
if [ -n "$OTEL_RESOURCE_ATTRIBUTES" ]; then
OTEL_RESOURCE_ATTRIBUTES="$OTEL_RESOURCE_ATTRIBUTES,service.instance.id=$FLY_MACHINE_ID"
else
OTEL_RESOURCE_ATTRIBUTES="service.instance.id=$FLY_MACHINE_ID"
fi
export OTEL_RESOURCE_ATTRIBUTES
fi
# Start supervisord which manages both nginx and zero-cache
exec supervisord -c /etc/supervisord.conf

View File

@@ -0,0 +1,23 @@
[supervisord]
nodaemon=true
logfile=/dev/stdout
logfile_maxbytes=0
pidfile=/run/supervisord.pid
user=root
[program:zero]
command=zero-cache
environment=ZERO_PORT=4849
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:nginx]
command=nginx -g "daemon off;"
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsxImportSource": "react",
"incremental": true,
"plugins": [{ "name": "next" }]
},
"include": ["**/*.ts", "**/*.tsx", "src/tla/layouts/TlaAnonLayout", "zero.config.ts"],
"exclude": ["node_modules", "_archive"],
"references": []
}

View File

@@ -0,0 +1,43 @@
import { readdirSync } from 'fs'
import { fileURLToPath } from 'url'
import { describe, expect, it } from 'vitest'
import { validateMigrationFilenames } from './validateMigrationFilenames'
const migrationsDir = fileURLToPath(new URL('./migrations', import.meta.url))
describe('validateMigrationFilenames', () => {
it('passes for the migrations checked into this package', () => {
const filenames = readdirSync(migrationsDir)
expect(() => validateMigrationFilenames(filenames)).not.toThrow()
})
it('accepts a clean contiguous sequence', () => {
expect(() =>
validateMigrationFilenames(['000_seed.sql', '001_a.sql', '002_b.sql'])
).not.toThrow()
})
it('throws on a duplicate number', () => {
expect(() => validateMigrationFilenames(['000_seed.sql', '001_a.sql', '001_b.sql'])).toThrow(
/Duplicate migration number 001/
)
})
it('throws on a gap in the sequence', () => {
expect(() => validateMigrationFilenames(['000_seed.sql', '002_b.sql'])).toThrow(/missing 001/)
})
it('throws when the sequence does not start at 000', () => {
expect(() => validateMigrationFilenames(['001_a.sql'])).toThrow(/must start at 000/)
})
it('throws on a malformed filename', () => {
expect(() => validateMigrationFilenames(['1_a.sql'])).toThrow(/must match/)
})
it('allows explicitly grandfathered duplicate numbers', () => {
expect(() =>
validateMigrationFilenames(['000_seed.sql', '001_a.sql', '001_b.sql'], new Set([1]))
).not.toThrow()
})
})

View File

@@ -0,0 +1,64 @@
// Migration numbers are chosen by hand, so two concurrent PRs can pick the same
// one. The filenames differ, so git merges both cleanly and `main` ends up with
// a duplicate. This throws on duplicate or non-contiguous numbers; CI runs it
// against the post-merge tree, catching the collision before it lands.
// Numbers duplicated before this check existed. Don't add to this list.
export const GRANDFATHERED_DUPLICATE_NUMBERS: ReadonlySet<number> = new Set([27])
function pad(n: number) {
return String(n).padStart(3, '0')
}
export function validateMigrationFilenames(
filenames: string[],
allowedDuplicates: ReadonlySet<number> = GRANDFATHERED_DUPLICATE_NUMBERS
) {
const filesByNumber = new Map<number, string[]>()
for (const filename of filenames) {
const match = filename.match(/^(\d{3})_.+\.sql$/)
if (!match) {
throw new Error(
`Migration filename "${filename}" must match "NNN_description.sql" (a three-digit number, for example "032_add_thing.sql").`
)
}
const number = Number(match[1])
const existing = filesByNumber.get(number)
if (existing) {
existing.push(filename)
} else {
filesByNumber.set(number, [filename])
}
}
if (filesByNumber.size === 0) {
throw new Error('No migrations found.')
}
const numbers = [...filesByNumber.keys()].sort((a, b) => a - b)
// Numbers must be unique.
for (const number of numbers) {
const files = filesByNumber.get(number)!
if (files.length > 1 && !allowedDuplicates.has(number)) {
throw new Error(
`Duplicate migration number ${pad(number)}: ${[...files].sort().join(', ')}. ` +
`Each migration must use a unique number — bump one of them to the next free number.`
)
}
}
// Numbers must be contiguous starting at 000.
if (numbers[0] !== 0) {
throw new Error(`Migrations must start at 000, but the lowest number is ${pad(numbers[0])}.`)
}
for (let i = 1; i < numbers.length; i++) {
const prev = numbers[i - 1]
const curr = numbers[i]
if (curr !== prev + 1) {
throw new Error(
`Migration numbers must be contiguous: missing ${pad(prev + 1)} (jumps from ${pad(prev)} to ${pad(curr)}).`
)
}
}
}

View File

@@ -0,0 +1,12 @@
/// <reference types="vitest" />
import { mergeConfig } from 'vitest/config'
import baseConfig from '../../../internal/config/vitest/node-preset'
// Node env; source lives at the package root, not `src/`, so widen the include.
export default mergeConfig(baseConfig, {
test: {
environment: 'node',
include: ['**/*.test.ts'],
exclude: ['**/node_modules/**', 'docker/**', '_archive/**'],
},
})