Files
upstream-tldraw/apps/dotcom/zero-cache/stamp_comment_created_at.test.ts
upstream-mirror a774ac673a
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
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

233 lines
8.9 KiB
TypeScript

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)
})
})