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