Skip to content

Commit e110e90

Browse files
committed
fix(dashboard-agent-db): backfill last_read_at so existing chats start read
The column was added nullable and every reader treats NULL as unread, so the first load after rollout reported every pre-existing chat unread.
1 parent e02e0f7 commit e110e90

2 files changed

Lines changed: 140 additions & 0 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import {
2+
countChatsWithUnreadWork,
3+
createDashboardAgentDb,
4+
type DashboardAgentDb,
5+
type DashboardAgentDbClient,
6+
} from "@internal/dashboard-agent-db";
7+
import { postgresTest } from "@internal/testcontainers";
8+
import type { PrismaClient } from "@trigger.dev/database";
9+
import { readFileSync } from "node:fs";
10+
import path from "node:path";
11+
import { afterEach, describe, expect } from "vitest";
12+
13+
/**
14+
* `chats.last_read_at` is nullable and every reader treats NULL as unread, so without a
15+
* backfill the first load after rollout reports every pre-existing chat unread. Migration
16+
* 0002 backfills it; this replays the migrations against a real Postgres to prove it does.
17+
*/
18+
19+
const DRIZZLE = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
20+
21+
const MIGRATIONS = [
22+
"0000_magenta_lilandra.sql",
23+
"0001_slimy_living_tribunal.sql",
24+
"0002_watches_and_chat_messages.sql",
25+
];
26+
27+
/** The statement under test, located by shape so removing it fails rather than silently passing. */
28+
const BACKFILL = /^update\s+"trigger_dashboard_agent"\."chats"\s+set\s+"last_read_at"/i;
29+
30+
function statementsOf(file: string): string[] {
31+
return readFileSync(path.join(DRIZZLE, file), "utf8")
32+
.split("--> statement-breakpoint")
33+
.map((statement) =>
34+
statement
35+
.split("\n")
36+
.filter((line) => !line.trimStart().startsWith("--"))
37+
.join("\n")
38+
.trim()
39+
)
40+
.filter((statement) => statement.length > 0);
41+
}
42+
43+
async function run(prisma: PrismaClient, statements: string[]) {
44+
for (const statement of statements) await prisma.$executeRawUnsafe(statement);
45+
}
46+
47+
const SCOPE = { organizationId: "org_1", userId: "user_1" };
48+
49+
const CREATED_AT = new Date("2026-01-01T00:00:00.000Z");
50+
const LAST_MESSAGE_AT = new Date("2026-02-01T00:00:00.000Z");
51+
/** After the last message, so the chat is genuinely read and dropping the `where` moves it back. */
52+
const ALREADY_READ_AT = new Date("2026-03-01T00:00:00.000Z");
53+
54+
/** Chats as they exist before 0002 runs — no `last_read_at` column yet. */
55+
async function seedPreExistingChats(prisma: PrismaClient) {
56+
for (const [id, lastMessageAt] of [
57+
["chat_with_messages", LAST_MESSAGE_AT],
58+
["chat_never_messaged", null],
59+
["chat_already_read", LAST_MESSAGE_AT],
60+
] as const) {
61+
await prisma.$executeRawUnsafe(
62+
`insert into "trigger_dashboard_agent"."chats"
63+
("id", "organization_id", "user_id", "created_at", "updated_at", "last_message_at")
64+
values ($1, $2, $3, $4, $4, $5)`,
65+
id,
66+
SCOPE.organizationId,
67+
SCOPE.userId,
68+
CREATED_AT,
69+
lastMessageAt
70+
);
71+
}
72+
}
73+
74+
async function readLastReadAt(prisma: PrismaClient): Promise<Record<string, Date | null>> {
75+
const rows = await prisma.$queryRawUnsafe<Array<{ id: string; last_read_at: Date | null }>>(
76+
`select "id", "last_read_at" from "trigger_dashboard_agent"."chats" order by "id"`
77+
);
78+
return Object.fromEntries(rows.map((row) => [row.id, row.last_read_at]));
79+
}
80+
81+
let agentDbClient: DashboardAgentDbClient | undefined;
82+
83+
afterEach(async () => {
84+
await agentDbClient?.close();
85+
agentDbClient = undefined;
86+
});
87+
88+
describe("the last_read_at backfill in migration 0002", () => {
89+
postgresTest(
90+
"starts pre-existing chats read, and does not overwrite a chat already read",
91+
async ({ prisma, postgresContainer }) => {
92+
await run(prisma, statementsOf(MIGRATIONS[0]!));
93+
await run(prisma, statementsOf(MIGRATIONS[1]!));
94+
95+
const statements = statementsOf(MIGRATIONS[2]!);
96+
const backfillAt = statements.findIndex((statement) => BACKFILL.test(statement));
97+
expect(backfillAt, "0002 contains no last_read_at backfill statement").toBeGreaterThan(-1);
98+
99+
// Everything up to the backfill: the column exists, the chats predate it.
100+
await run(prisma, statements.slice(0, backfillAt));
101+
await seedPreExistingChats(prisma);
102+
// A deploy that rolled the column out ahead of the backfill could already have a value.
103+
await prisma.$executeRawUnsafe(
104+
`update "trigger_dashboard_agent"."chats" set "last_read_at" = $1 where "id" = 'chat_already_read'`,
105+
ALREADY_READ_AT
106+
);
107+
expect(await readLastReadAt(prisma)).toEqual({
108+
chat_with_messages: null,
109+
chat_never_messaged: null,
110+
chat_already_read: ALREADY_READ_AT,
111+
});
112+
113+
await run(prisma, statements.slice(backfillAt));
114+
115+
expect(await readLastReadAt(prisma)).toEqual({
116+
// Read as of its last message: a later message still lights the dot.
117+
chat_with_messages: LAST_MESSAGE_AT,
118+
// Nothing was ever said in it, so it is read as of the moment it existed.
119+
chat_never_messaged: CREATED_AT,
120+
// Already read; the backfill must not move it back or forward.
121+
chat_already_read: ALREADY_READ_AT,
122+
});
123+
124+
agentDbClient = createDashboardAgentDb(postgresContainer.getConnectionUri(), { max: 2 });
125+
const agentDb: DashboardAgentDb = agentDbClient.db;
126+
127+
// The user-visible claim: the launcher dot is dark on the first load after rollout.
128+
expect(await countChatsWithUnreadWork(agentDb, SCOPE)).toBe(0);
129+
130+
// And a positive control, so a backfill that marked everything read forever would fail.
131+
await prisma.$executeRawUnsafe(
132+
`update "trigger_dashboard_agent"."chats" set "last_message_at" = now() where "id" = 'chat_never_messaged'`
133+
);
134+
expect(await countChatsWithUnreadWork(agentDb, SCOPE)).toBe(1);
135+
}
136+
);
137+
});

internal-packages/dashboard-agent-db/drizzle/0002_watches_and_chat_messages.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ CREATE TABLE "trigger_dashboard_agent"."watches" (
8888
--> statement-breakpoint
8989
ALTER TABLE "trigger_dashboard_agent"."chats" ADD COLUMN IF NOT EXISTS "last_read_at" timestamp with time zone;
9090
--> statement-breakpoint
91+
-- Chats that predate the column start read, so only activity after this migration lights the dot.
92+
UPDATE "trigger_dashboard_agent"."chats" SET "last_read_at" = coalesce("last_message_at", "created_at") WHERE "last_read_at" IS NULL;
93+
--> statement-breakpoint
9194
ALTER TABLE "trigger_dashboard_agent"."chats" ADD COLUMN IF NOT EXISTS "next_message_position" integer DEFAULT 1 NOT NULL;
9295
--> statement-breakpoint
9396
CREATE INDEX "chat_messages_chat_user_role_idx" ON "trigger_dashboard_agent"."chat_messages" USING btree ("chat_id","message_id") WHERE "trigger_dashboard_agent"."chat_messages"."role" = 'user';

0 commit comments

Comments
 (0)