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