snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
12
apps/dotcom/zero-cache/.env
Normal file
12
apps/dotcom/zero-cache/.env
Normal 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
12
apps/dotcom/zero-cache/.gitignore
vendored
Normal 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
|
||||
14
apps/dotcom/zero-cache/Dockerfile.template
Normal file
14
apps/dotcom/zero-cache/Dockerfile.template
Normal 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"]
|
||||
256
apps/dotcom/zero-cache/delete_file_states.test.ts
Normal file
256
apps/dotcom/zero-cache/delete_file_states.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
53
apps/dotcom/zero-cache/docker/docker-compose.yml
Normal file
53
apps/dotcom/zero-cache/docker/docker-compose.yml
Normal 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
|
||||
21
apps/dotcom/zero-cache/docker/pgbouncer.ini
Normal file
21
apps/dotcom/zero-cache/docker/pgbouncer.ini
Normal 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
|
||||
844
apps/dotcom/zero-cache/docker/postgres-logging.conf
Normal file
844
apps/dotcom/zero-cache/docker/postgres-logging.conf
Normal 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
|
||||
1
apps/dotcom/zero-cache/docker/userlist.txt
Normal file
1
apps/dotcom/zero-cache/docker/userlist.txt
Normal file
@@ -0,0 +1 @@
|
||||
"user" "password"
|
||||
260
apps/dotcom/zero-cache/effect_outbox.test.ts
Normal file
260
apps/dotcom/zero-cache/effect_outbox.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
54
apps/dotcom/zero-cache/flyio-view-syncer.template.toml
Normal file
54
apps/dotcom/zero-cache/flyio-view-syncer.template.toml
Normal 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"
|
||||
46
apps/dotcom/zero-cache/flyio.template.toml
Normal file
46
apps/dotcom/zero-cache/flyio.template.toml
Normal 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'
|
||||
201
apps/dotcom/zero-cache/migrate.ts
Normal file
201
apps/dotcom/zero-cache/migrate.ts
Normal 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()
|
||||
57
apps/dotcom/zero-cache/migrationSql.test.ts
Normal file
57
apps/dotcom/zero-cache/migrationSql.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
18
apps/dotcom/zero-cache/migrationSql.ts
Normal file
18
apps/dotcom/zero-cache/migrationSql.ts
Normal 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
|
||||
)
|
||||
}
|
||||
72
apps/dotcom/zero-cache/migrations/000_seed.sql
Normal file
72
apps/dotcom/zero-cache/migrations/000_seed.sql
Normal 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();
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE "replicator_boot_id" (
|
||||
"replicatorId" VARCHAR NOT NULL PRIMARY KEY,
|
||||
"bootId" VARCHAR NOT NULL
|
||||
);
|
||||
9
apps/dotcom/zero-cache/migrations/002_add_user_id.sql
Normal file
9
apps/dotcom/zero-cache/migrations/002_add_user_id.sql
Normal 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");
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Purpose: Make the publishedSlug column unique in the file table.
|
||||
|
||||
ALTER TABLE public.file
|
||||
ADD CONSTRAINT unique_publishedSlug UNIQUE ("publishedSlug");
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
4
apps/dotcom/zero-cache/migrations/008_add_pinned.sql
Normal file
4
apps/dotcom/zero-cache/migrations/008_add_pinned.sql
Normal 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;
|
||||
@@ -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;
|
||||
8
apps/dotcom/zero-cache/migrations/010_add_asset_info.sql
Normal file
8
apps/dotcom/zero-cache/migrations/010_add_asset_info.sql
Normal 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")
|
||||
);
|
||||
@@ -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;
|
||||
24
apps/dotcom/zero-cache/migrations/012_prevent_deadlocks.sql
Normal file
24
apps/dotcom/zero-cache/migrations/012_prevent_deadlocks.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
1
apps/dotcom/zero-cache/migrations/015_full_replicas.sql
Normal file
1
apps/dotcom/zero-cache/migrations/015_full_replicas.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE file REPLICA IDENTITY FULL;
|
||||
@@ -0,0 +1 @@
|
||||
CREATE PUBLICATION zero_data FOR TABLE file, file_state, public.user;
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Purpose: Renames showUiLabels column to enhancedA11yMode column in the user table
|
||||
|
||||
ALTER TABLE "user"
|
||||
RENAME COLUMN "showUiLabels" TO "enhancedA11yMode";
|
||||
@@ -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'));
|
||||
451
apps/dotcom/zero-cache/migrations/023_groups.sql
Normal file
451
apps/dotcom/zero-cache/migrations/023_groups.sql
Normal 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;
|
||||
15
apps/dotcom/zero-cache/migrations/024_add_fairies.sql
Normal file
15
apps/dotcom/zero-cache/migrations/024_add_fairies.sql
Normal 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
|
||||
);
|
||||
13
apps/dotcom/zero-cache/migrations/025_fairy_gating.sql
Normal file
13
apps/dotcom/zero-cache/migrations/025_fairy_gating.sql
Normal 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
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE user_fairies
|
||||
ADD COLUMN "weeklyUsage" JSONB DEFAULT '{}'::jsonb;
|
||||
13
apps/dotcom/zero-cache/migrations/027_add_fairy_messages.sql
Normal file
13
apps/dotcom/zero-cache/migrations/027_add_fairy_messages.sql
Normal 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");
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE fairy_invite
|
||||
ADD COLUMN description TEXT,
|
||||
ADD COLUMN "redeemedBy" JSONB DEFAULT '[]'::jsonb;
|
||||
@@ -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");
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE user_fairies
|
||||
ADD COLUMN "weeklyLimit" INTEGER DEFAULT 25;
|
||||
@@ -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));
|
||||
@@ -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");
|
||||
@@ -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 $$;
|
||||
@@ -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'));
|
||||
@@ -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;
|
||||
14
apps/dotcom/zero-cache/migrations/035_welcome_template.sql
Normal file
14
apps/dotcom/zero-cache/migrations/035_welcome_template.sql
Normal 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
|
||||
);
|
||||
@@ -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");
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE public."group"
|
||||
ADD COLUMN "inviteLinkEnabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
115
apps/dotcom/zero-cache/migrations/040_add_comments.sql
Normal file
115
apps/dotcom/zero-cache/migrations/040_add_comments.sql
Normal 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";
|
||||
20
apps/dotcom/zero-cache/migrations/041_add_comment_read.sql
Normal file
20
apps/dotcom/zero-cache/migrations/041_add_comment_read.sql
Normal 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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
97
apps/dotcom/zero-cache/migrations/044_file_visitor.sql
Normal file
97
apps/dotcom/zero-cache/migrations/044_file_visitor.sql
Normal 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;
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
53
apps/dotcom/zero-cache/migrations/047_effect_outbox.sql
Normal file
53
apps/dotcom/zero-cache/migrations/047_effect_outbox.sql
Normal 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();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
31
apps/dotcom/zero-cache/migrations/052_file_search_index.sql
Normal file
31
apps/dotcom/zero-cache/migrations/052_file_search_index.sql
Normal 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);
|
||||
90
apps/dotcom/zero-cache/nginx.conf.template
Normal file
90
apps/dotcom/zero-cache/nginx.conf.template
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
apps/dotcom/zero-cache/package.json
Normal file
33
apps/dotcom/zero-cache/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
232
apps/dotcom/zero-cache/stamp_comment_created_at.test.ts
Normal file
232
apps/dotcom/zero-cache/stamp_comment_created_at.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
38
apps/dotcom/zero-cache/start.sh
Normal file
38
apps/dotcom/zero-cache/start.sh
Normal 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
|
||||
23
apps/dotcom/zero-cache/supervisord.conf
Normal file
23
apps/dotcom/zero-cache/supervisord.conf
Normal 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
|
||||
23
apps/dotcom/zero-cache/tsconfig.json
Normal file
23
apps/dotcom/zero-cache/tsconfig.json
Normal 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": []
|
||||
}
|
||||
43
apps/dotcom/zero-cache/validateMigrationFilenames.test.ts
Normal file
43
apps/dotcom/zero-cache/validateMigrationFilenames.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
64
apps/dotcom/zero-cache/validateMigrationFilenames.ts
Normal file
64
apps/dotcom/zero-cache/validateMigrationFilenames.ts
Normal 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)}).`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
12
apps/dotcom/zero-cache/vitest.config.ts
Normal file
12
apps/dotcom/zero-cache/vitest.config.ts
Normal 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/**'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user