Files
PaperClipAI/packages/db/src/chat-identity-migration-reconciliation.test.ts
T
Nicky LeachandClaude Opus 5 aeef493f4a chore(db): keep only the newest 5 drizzle snapshots and stop shipping them (#13687)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - `@paperclipai/db` owns the Drizzle schema and the migration history
> - Drizzle writes a full copy of the schema as a snapshot for each
generated migration. Each snapshot is now about 1.3 MB.
> - The `meta/` folder is about 103 MB. That is about half of each
checkout and each worktree. The build also copies it into `dist`, so the
published `@paperclipai/db` package is 112.8 MB unpacked.
> - `drizzle-kit generate` reads only the newest snapshot. The runtime
migrator reads only the `.sql` files and `_journal.json`.
> - This pull request keeps the newest 5 snapshots and removes snapshots
from `dist`.
> - The benefit is a checkout that is about 100 MB smaller, and a
published package that is about 1.5 MB instead of 113 MB.

## Linked Issues or Issue Description

Refs #11240, #11254, #12333 (earlier snapshot work: diff collapse,
binary diffs, drift repair)

**What existing behavior does this improve?**
The size of the Drizzle migration snapshots in the repository and in the
published `@paperclipai/db` package.

**Subsystem affected**
`packages/db`: migrations and the build.

**Current behavior**
`packages/db/src/migrations/meta/` holds 141 snapshots (102.8 MB). The
size grows faster than the number of migrations, because each snapshot
is a full copy of the schema. `build` runs `cp -r src/migrations
dist/migrations`. `@paperclipai/db@2026.916.0` contains 147 snapshot
files. It is 112.8 MB unpacked and 5.3 MB as a tarball.

**Proposed behavior**
Keep the newest 5 snapshots. `generate` deletes older snapshots after it
runs. `dist` gets only `*.sql` and `meta/_journal.json`.

**Reason and benefit**
- In drizzle-kit 0.31.10, `generate` sorts `meta/*` and diffs against
the last snapshot only (`bin.cjs`, `preparePrevSnapshot`). The [generate
docs](https://orm.drizzle.team/docs/drizzle-kit-generate) also say it
compares against "the most recent" snapshot.
- Gaps in the snapshot history already work. 138 of the 280 migrations
never had a snapshot, because they were written by hand before
`doc/DATABASE.md` required `generate`.
- We keep 5 snapshots instead of 1. This lets a developer undo the
latest generated migration, and it keeps the `prevId` chain for recent
branches.
- Git keeps the history cheaply. The 631 snapshot versions use only 2.2
MB of the pack, because git stores each version as a delta of the
previous one. The cost is in the checked-out files, not the clone
download. Therefore this change does not use git-lfs and does not
rewrite history. Old snapshots stay available with `git show
<rev>:<path>`.

## What Changed

- `packages/db/package.json`: a new `prune:snapshots` script keeps the
newest 5 `*_snapshot.json` files. It is `ls | sort -r | tail -n +6 |
xargs rm -f`, which works with the BSD tools on macOS and the GNU tools
on Linux. `generate` runs this script after `drizzle-kit generate`.
- `packages/db/package.json`: `build` copies only `src/migrations/*.sql`
and `meta/_journal.json` into `dist/migrations`.
- Deleted 137 older snapshots. `0277`–`0281` remain.
- `chat-identity-migration-reconciliation.test.ts`: removed the walk
over snapshots `0254`–`0268`. Those files do not change after merge, and
the walk would fail after pruning. The journal-order assertions in the
same test remain. `migration-snapshot-drift.test.ts` still makes sure
that the newest snapshot matches the schema.
- `doc/DATABASE.md`: documented the retention rule.
- The snapshots were already marked `linguist-generated=true -diff
-merge` by `packages/db/.gitattributes` (#11240). No change there. `git
check-attr` confirms it.

## Verification

- `pnpm --filter @paperclipai/db exec vitest run`: 43 files, 160 tests
pass.
- `pnpm --filter @paperclipai/db build`: `dist/migrations` contains 280
`.sql` files and `meta/_journal.json`. It is 1.5 MB, compared with about
105 MB before.
- `prune:snapshots` was run on macOS (BSD) and in `debian:stable-slim`
(GNU findutils 4.10). With 7 fixture snapshots, it keeps the newest 5.
When 5 or fewer are present, it deletes nothing and exits 0 on both.

## Risks

- Low risk. Runtime migration does not read snapshots. The only commands
that read older snapshots are `drizzle-kit check` and `drizzle-kit
drop`. No script or CI job calls them, and they still have the newest 5
snapshots.
- A branch that is open now can still add its own snapshot. If the
branch conflicts, the rule is the same as today: renumber the migration
and run `generate` again.
- When we upgrade to drizzle-kit v1 (folder per migration), check
whether its new cross-branch "commutativity" checks need a longer
snapshot history.

## Model Used

- Claude Opus 5 (`claude-opus-5`) in Claude Code, with tool use (shell,
file edits, web fetch).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 10:40:38 -07:00

732 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash, randomUUID } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import { describe, expect, it } from "vitest";
import {
applyPendingMigrations,
closeRegisteredClients,
ensurePostgresDatabase,
inspectMigrations,
} from "./client.js";
import {
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./test-embedded-postgres.js";
// The deployed chat branch used 0240–0249 before master added its independent
// 0240–0245 execution-identity chain. These are the exact original SQL hashes,
// not hashes of regenerated DDL: the interaction migration also repairs data
// and must never run again just because its filename moved.
// The next upstream chain occupies0246–0254. Chat files now use0255–0268;
// historical data-repair audit labels remain byte-identical.
const chatMigrations = [
[
"0255_previous_captain_america",
"2cbd1eb88d3bf4c82b72fdfd78dce72ecd7899f85607a76fb7e40b2749fffa00",
1788580015986,
],
[
"0256_married_king_cobra",
"f352a8769496412df3be35050df02110714af3a189d92b3c2ac8d097e2612c7b",
1788581746772,
],
[
"0257_bizarre_the_hunter",
"4e4636a22fb06aac55a998a0c98d043ec70083c198c94a0f5e0a99f18a1debac",
1788582768429,
],
[
"0258_typical_sauron",
"f3cb8b9d3bb3691d98a830c7ba8b4f49bbe9bb01ce2e899583d0b5c9b84d423c",
1788585030341,
],
[
"0259_tan_chat",
"91012c36bfcf66615b537ce808c9cb2f1311bc6efa6aab3ed8afd0d01298af75",
1788673647823,
],
[
"0260_chat_interaction_wakeup_idempotency",
"5e181169a724173d17865d537bd84c385e97e6f78e71aa795cad91734cd37ea0",
1788688205087,
],
[
"0261_faulty_iceman",
"dd7a7571e080471148cff98d1138ddd046e8c1e3c256fa5bc11564d5f4766c28",
1788704871875,
],
[
"0262_lying_avengers",
"59909e4edae56117c7fe0af28aa10fe30a64d151e83fe6e06debcec90658ff09",
1788708784607,
],
[
"0263_nebulous_iron_lad",
"858eb11c0863e361c1ae6995e78ca365f8002e35dcb1e89dbcc8bf65dea879e3",
1788714691806,
],
[
"0264_cynical_hellcat",
"d9aeacc58ae3c52d34bf50f8ea38f55435a6f8f66dc6ee87d3b78e105a86602a",
1788793844054,
],
[
"0265_chat_interaction_wakeup_provenance",
"1547e6e597b50c621691ead1d25624c4bf94ca3259cf24ea4480f3fb915dd849",
1788880065244,
],
[
"0266_brave_living_mummy",
"7c38ccd2fa6a9bde19d62b111f892bcabae9a8eabe5f8bf438f73a407016b56a",
1788930085103,
],
[
"0267_warm_wild_child",
"6902ea71d481a26d6359c6c9b149ff0c8a388e65356b066b2fbaa33622a6c9b8",
1788934048647,
],
[
"0268_lively_runaways",
"20ebd2ac15d9b467abcc5552901ecf592b38499942b3eae7d593c79fd89bd0a8",
1788942847296,
],
] as const;
const identityMigrations = [
"0240_pink_fantastic_four.sql",
"0241_conscious_adam_destine.sql",
"0242_wide_lightspeed.sql",
"0243_sleepy_metal_master.sql",
"0244_organic_meltdown.sql",
"0245_misty_nightshade.sql",
];
const provenanceMigration = "0265_chat_interaction_wakeup_provenance.sql";
const legacyProvenance = "0245_chat_interaction_wakeup_idempotency";
const canonicalProvenance = "0251_chat_interaction_wakeup_idempotency";
async function executeMigration(sql: postgres.Sql, file: string) {
const content = await readFile(
new URL(`./migrations/${file}`, import.meta.url),
"utf8",
);
for (const statement of content.split("--> statement-breakpoint")) {
if (statement.trim()) await sql.unsafe(statement);
}
}
async function migrationHash(file: string) {
const content = await readFile(
new URL(`./migrations/${file}`, import.meta.url),
);
return createHash("sha256").update(content).digest("hex");
}
describe("chat and execution identity migration reconciliation", () => {
it("preserves all fourteen deployed chat SQL hashes after renumbering", async () => {
for (const [tag, hash] of chatMigrations) {
expect(await migrationHash(`${tag}.sql`), tag).toBe(hash);
}
});
it("keeps the identity and chat migrations in their reconciled journal order", async () => {
const journal = JSON.parse(
await readFile(
new URL("./migrations/meta/_journal.json", import.meta.url),
"utf8",
),
);
expect(
journal.entries
.filter(
(entry: { idx: number }) => entry.idx >= 240 && entry.idx <= 245,
)
.map((entry: { tag: string }) => `${entry.tag}.sql`),
).toEqual(identityMigrations);
expect(
journal.entries
.filter(
(entry: { idx: number }) => entry.idx >= 255 && entry.idx <= 268,
)
.map((entry: { tag: string }) => entry.tag),
).toEqual(chatMigrations.map(([tag]) => tag));
});
});
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)(
"chat wake migration provenance",
() => {
it(
"repairs deployed provenance without replaying historical SQL or changing wake execution state",
async () => {
const database = await startEmbeddedPostgresTestDatabase(
"paperclip-chat-provenance-",
);
const sql = postgres(database.connectionString, {
max: 1,
onnotice: () => {},
});
try {
const companyId = randomUUID(),
agentId = randomUUID(),
retainedId = randomUUID();
await sql`INSERT INTO companies (id,name,issue_prefix) VALUES (${companyId},'Provenance upgrade','PRV')`;
await sql`INSERT INTO agents (id,company_id,name) VALUES (${agentId},${companyId},'Upgrade agent')`;
const originalKey = `interaction:${randomUUID()}`;
const oldLine = `Safely retired duplicate by migration 0245; retained wake request ${retainedId}`;
const newLine = `Safely retired duplicate by migration 0251; retained wake request ${retainedId}`;
const retired = {
migration: legacyProvenance,
retainedWakeRequestId: retainedId,
originalIdempotencyKey: originalKey,
previousStatus: "queued",
linkedRunId: null,
resolution: "retired_unstarted_duplicate",
futureAuditField: { preserved: true },
};
const cases: Array<{
name: string;
dedupe: postgres.JSONValue;
key?: string;
status?: string;
error?: string | null;
correct?: boolean;
correctedError?: string | null;
id?: string;
}> = [
{
name: "retired first UUID",
id: "00000000-0000-0000-0000-000000000000",
dedupe: retired,
error: oldLine,
correct: true,
correctedError: newLine,
},
{
name: "preserves unrelated audit prefix",
dedupe: retired,
error: `Unrelated migration 0245 note\n${oldLine}`,
correct: true,
correctedError: `Unrelated migration 0245 note\n${newLine}`,
},
{
name: "preserves non-final generated line",
dedupe: retired,
error: `${oldLine}\nLater operator note`,
correct: true,
},
{
name: "does not replace arbitrary suffix",
dedupe: retired,
error: `Operator quoted: ${oldLine}`,
correct: true,
},
{
name: "does not replace another retained ID",
dedupe: retired,
error: oldLine.replace(retainedId, randomUUID()),
correct: true,
},
{
name: "later terminalized rekeyed wake",
dedupe: {
...retired,
previousStatus: "running",
linkedRunId: randomUUID(),
resolution: "rekeyed_preserving_execution_history",
},
key: "historical",
status: "failed",
error: oldLine,
correct: true,
},
{
name: "already canonical",
dedupe: { ...retired, migration: canonicalProvenance },
error: oldLine,
},
{
name: "unrelated migration",
dedupe: { ...retired, migration: "0245_misty_nightshade" },
error: oldLine,
},
{ name: "array metadata", dedupe: [retired] },
{ name: "null metadata", dedupe: null },
{ name: "string metadata", dedupe: legacyProvenance },
{
name: "unknown resolution",
dedupe: { ...retired, resolution: "unknown" },
},
{
name: "missing linked-run field",
dedupe: { ...retired, linkedRunId: undefined },
},
{
name: "malformed retained ID",
dedupe: { ...retired, retainedWakeRequestId: "not-a-uuid" },
},
{
name: "malformed linked-run ID",
dedupe: {
...retired,
linkedRunId: "not-a-uuid",
resolution: "rekeyed_preserving_execution_history",
},
key: "historical",
},
{
name: "non-interaction source key",
dedupe: { ...retired, originalIdempotencyKey: "timer:unrelated" },
},
{
name: "non-string previous status",
dedupe: { ...retired, previousStatus: ["queued"] },
},
{
name: "inconsistent retired resolution",
dedupe: { ...retired, previousStatus: "running" },
},
{
name: "inconsistent rekeyed resolution",
dedupe: {
...retired,
resolution: "rekeyed_preserving_execution_history",
},
key: "historical",
},
{
name: "unrelated current key",
dedupe: retired,
key: "timer:unrelated",
},
];
// More than one keyset batch, including an unrelated-only middle batch.
await sql`INSERT INTO agent_wakeup_requests (id,company_id,agent_id,source,payload)
SELECT ('00000000-0000-0000-0001-' || lpad(n::text,12,'0'))::uuid, ${companyId}, ${agentId}, 'timer', '{"unrelated":true}'::jsonb
FROM generate_series(1,1001) AS n`;
const expectedChanges = new Map<
string,
{ error: string | null; payload: Record<string, unknown> }
>();
for (const fixture of cases) {
const id = fixture.id ?? randomUUID();
const key =
fixture.key === "historical"
? `historical-interaction-wake-duplicate:${id}`
: (fixture.key ?? originalKey);
const payload = {
unrelated: { preserved: true },
migrationDedupe: fixture.dedupe,
};
const error = fixture.error ?? null;
await sql`INSERT INTO agent_wakeup_requests
(id,company_id,agent_id,source,reason,status,idempotency_key,run_id,payload,error,requested_at,claimed_at,finished_at,created_at,updated_at)
VALUES (${id},${companyId},${agentId},'automation',${fixture.name},${fixture.status ?? "skipped"},${key},${fixture.status === "failed" ? randomUUID() : null},${sql.json(payload)},${error},'2026-09-01T00:00:00Z','2026-09-01T00:00:01Z','2026-09-01T00:00:02Z','2026-09-01T00:00:00Z','2026-09-01T00:00:02Z')`;
if (fixture.correct)
expectedChanges.set(id, {
payload: {
...payload,
migrationDedupe: {
...(fixture.dedupe as object),
migration: canonicalProvenance,
},
},
error: fixture.correctedError ?? error,
});
}
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${await migrationHash(provenanceMigration)}`;
const legacyHash = chatMigrations[5][1];
await sql`UPDATE drizzle.__drizzle_migrations SET created_at = ${chatMigrations[5][2]} WHERE hash = ${legacyHash}`;
const historyBefore =
await sql`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations ORDER BY id`;
expect(
historyBefore.filter((row) => row.hash === legacyHash),
).toHaveLength(1);
const rowsBefore =
await sql`SELECT row_to_json(w) AS row FROM agent_wakeup_requests w WHERE company_id = ${companyId} ORDER BY id`;
const expected = rowsBefore.map(({ row }) => ({
row: { ...row, ...expectedChanges.get(row.id) },
}));
expect(
await inspectMigrations(database.connectionString),
).toMatchObject({
status: "needsMigrations",
pendingMigrations: [provenanceMigration],
});
await applyPendingMigrations(database.connectionString);
expect(
await sql`SELECT row_to_json(w) AS row FROM agent_wakeup_requests w WHERE company_id = ${companyId} ORDER BY id`,
).toEqual(expected);
expect(
await sql`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations WHERE id <= ${historyBefore.at(-1)!.id} ORDER BY id`,
).toEqual(historyBefore);
const historyAfter =
await sql`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations ORDER BY id`;
expect(historyAfter).toHaveLength(historyBefore.length + 1);
await executeMigration(sql, provenanceMigration);
await applyPendingMigrations(database.connectionString);
expect(
await sql`SELECT row_to_json(w) AS row FROM agent_wakeup_requests w WHERE company_id = ${companyId} ORDER BY id`,
).toEqual(expected);
expect(
await sql`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations ORDER BY id`,
).toEqual(historyAfter);
} finally {
await sql.end();
await database.cleanup();
}
},
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
);
it(
"corrects both original repair resolutions on a fresh migration path",
async () => {
const database = await startEmbeddedPostgresTestDatabase(
"paperclip-chat-provenance-fresh-",
);
const sql = postgres(database.connectionString, {
max: 1,
onnotice: () => {},
});
try {
const companyId = randomUUID(),
agentId = randomUUID(),
retainedId = randomUUID(),
queuedId = randomUUID(),
activeId = randomUUID();
await sql`INSERT INTO companies (id,name,issue_prefix) VALUES (${companyId},'Fresh provenance','FPV')`;
await sql`INSERT INTO agents (id,company_id,name) VALUES (${agentId},${companyId},'Fresh agent')`;
await sql`DROP INDEX agent_wakeup_requests_question_response_delivery_idempotency_uq`;
const key = `interaction:${randomUUID()}`;
for (const [id, status, runId] of [
[retainedId, "succeeded", randomUUID()],
[queuedId, "queued", null],
[activeId, "running", randomUUID()],
] as const) {
await sql`INSERT INTO agent_wakeup_requests (id,company_id,agent_id,source,status,run_id,idempotency_key,payload,error)
VALUES (${id},${companyId},${agentId},'automation',${status},${runId},${key},'{"preserved":true}'::jsonb,'Prior audit')`;
}
await executeMigration(sql, `${chatMigrations[5][0]}.sql`);
const before =
await sql`SELECT row_to_json(w) AS row FROM agent_wakeup_requests w WHERE company_id = ${companyId} ORDER BY id`;
expect(
before.filter(
({ row }) =>
row.payload.migrationDedupe?.migration === legacyProvenance,
),
).toHaveLength(2);
expect(
before.find(({ row }) => row.id === queuedId)!.row.status,
).toBe("skipped");
expect(
before.find(({ row }) => row.id === activeId)!.row.status,
).toBe("running");
await executeMigration(sql, provenanceMigration);
const expected = before.map(({ row }) =>
row.id === retainedId
? { row }
: {
row: {
...row,
payload: {
...row.payload,
migrationDedupe: {
...row.payload.migrationDedupe,
migration: canonicalProvenance,
},
},
error:
row.id === queuedId
? `Prior audit\nSafely retired duplicate by migration 0251; retained wake request ${retainedId}`
: row.error,
},
},
);
expect(
await sql`SELECT row_to_json(w) AS row FROM agent_wakeup_requests w WHERE company_id = ${companyId} ORDER BY id`,
).toEqual(expected);
await executeMigration(sql, provenanceMigration);
expect(
await sql`SELECT row_to_json(w) AS row FROM agent_wakeup_requests w WHERE company_id = ${companyId} ORDER BY id`,
).toEqual(expected);
} finally {
await sql.end();
await database.cleanup();
}
},
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
);
},
);
(support.supported ? describe : describe.skip)(
"chat identity migration upgrade",
() => {
it(
"upgrades the actual prior chat schema through the new upstream chain without replaying chat effects",
async () => {
const database = await startEmbeddedPostgresTestDatabase(
"paperclip-chat-upstream-upgrade-",
);
const source = postgres(database.connectionString, {
max: 1,
onnotice: () => {},
});
const directory = await mkdtemp(
join(tmpdir(), "paperclip-chat-old-migrations-"),
);
const name = `legacy_chat_${randomUUID().replaceAll("-", "")}`;
const legacyUrl = new URL(database.connectionString);
legacyUrl.pathname = `/${name}`;
let legacy: postgres.Sql | null = null;
try {
expect(
await ensurePostgresDatabase(database.connectionString, name),
).toBe("created");
legacy = postgres(legacyUrl.href, { max: 1, onnotice: () => {} });
const journal = JSON.parse(
await readFile(
new URL("./migrations/meta/_journal.json", import.meta.url),
"utf8",
),
);
const entries = journal.entries as Array<{
idx: number;
tag: string;
when: number;
version: string;
breakpoints: boolean;
}>;
const priorEntries = entries
.filter(
(entry) => entry.idx < 246 || (entry.idx >= 255 && entry.idx <= 268),
)
.map((entry, index) => ({
...entry,
idx: index,
// Exact immutable007 chat-history timestamps. Filenames are not
// persisted by Drizzle; the SQL hash and applied time are.
when: entry.idx < 255 ? entry.when : [
1788832469741, 1788832471197, 1788832472637,
1788832474071, 1788832475492, 1788832476957,
1788832478340, 1788832479792, 1788832481237,
1788832482645, 1788880065244, 1788930085103,
1788934048647, 1788942847296,
][entry.idx - 255]!,
}));
expect(priorEntries.every((entry) => Number.isFinite(entry.when))).toBe(true);
await mkdir(join(directory, "meta"));
for (const entry of priorEntries) {
await writeFile(
join(directory, `${entry.tag}.sql`),
await readFile(
new URL(`./migrations/${entry.tag}.sql`, import.meta.url),
),
);
}
await writeFile(
join(directory, "meta/_journal.json"),
JSON.stringify({ ...journal, entries: priorEntries }),
);
// Run the real migration engine over the prior schema, not a current
// schema with fabricated applied-history rows or dropped columns.
await migrate(drizzle(legacy), { migrationsFolder: directory });
const companyId = randomUUID(),
agentId = randomUUID(),
applicationId = randomUUID(),
connectionId = randomUUID(),
endpointId = randomUUID(),
issueId = randomUUID(),
conversationId = randomUUID(),
publicationId = randomUUID();
await legacy`INSERT INTO companies (id,name,issue_prefix) VALUES (${companyId},'Prior chat schema','OLD')`;
await legacy`INSERT INTO agents (id,company_id,name) VALUES (${agentId},${companyId},'Prior agent')`;
await legacy`INSERT INTO tool_applications (id,company_id,name,type) VALUES (${applicationId},${companyId},'Slack','rest_api')`;
await legacy`INSERT INTO tool_connections (id,company_id,application_id,name,uid,connection_purpose,transport) VALUES (${connectionId},${companyId},${applicationId},'Slack','old-chat','channel','chat_sdk')`;
await legacy`INSERT INTO chat_endpoints (id,company_id,connection_id,provider,public_id,assigned_agent_id) VALUES (${endpointId},${companyId},${connectionId},'slack',${randomUUID()},${agentId})`;
await legacy`INSERT INTO issues (id,company_id,title) VALUES (${issueId},${companyId},'Retained source')`;
await legacy`INSERT INTO chat_conversations (id,company_id,endpoint_id,issue_id,external_conversation_id,external_thread_id,external_label) VALUES (${conversationId},${companyId},${endpointId},${issueId},'COLD','slack:COLD:1700.1','Retained thread')`;
await legacy`INSERT INTO chat_publications (id,company_id,endpoint_id,conversation_id,issue_id,idempotency_key,payload,state,attempts) VALUES (${publicationId},${companyId},${endpointId},${conversationId},${issueId},'retained-unknown','{"text":"Do not resend"}'::jsonb,'delivery_unknown',1)`;
const before =
await legacy`SELECT row_to_json(p) AS row FROM chat_publications p ORDER BY id`;
const historyBefore =
await legacy`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations ORDER BY id`;
const pending = entries
.filter(
(entry) => (entry.idx >= 246 && entry.idx <= 254) || entry.idx > 268,
)
.map((entry) => `${entry.tag}.sql`);
expect(pending).toHaveLength(9 + entries.filter((entry) => entry.idx > 268).length);
expect(await inspectMigrations(legacyUrl.href)).toMatchObject({
status: "needsMigrations",
pendingMigrations: pending,
});
await applyPendingMigrations(legacyUrl.href);
expect((await inspectMigrations(legacyUrl.href)).status).toBe(
"upToDate",
);
expect(
await legacy`SELECT row_to_json(p) AS row FROM chat_publications p ORDER BY id`,
).toEqual(before);
const historyAfter =
await legacy`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations ORDER BY id`;
expect(historyAfter.slice(0, historyBefore.length)).toEqual(
historyBefore,
);
expect(historyAfter).toHaveLength(
historyBefore.length + pending.length,
);
for (const [, hash] of chatMigrations)
expect(
historyAfter.filter((row) => row.hash === hash),
).toHaveLength(1);
const schema = async (sql: postgres.Sql) => ({
columns:
await sql`SELECT table_name,column_name,data_type,is_nullable,column_default FROM information_schema.columns WHERE table_schema='public' ORDER BY table_name,column_name`,
indexes:
await sql`SELECT tablename,indexname,indexdef FROM pg_indexes WHERE schemaname='public' ORDER BY tablename,indexname`,
constraints:
await sql`SELECT c.relname,k.conname,pg_get_constraintdef(k.oid) AS definition FROM pg_constraint k JOIN pg_class c ON c.oid=k.conrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' ORDER BY c.relname,k.conname`,
});
expect(await schema(legacy)).toEqual(await schema(source));
await applyPendingMigrations(legacyUrl.href);
expect(
await legacy`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations ORDER BY id`,
).toEqual(historyAfter);
expect(
await legacy`SELECT row_to_json(p) AS row FROM chat_publications p ORDER BY id`,
).toEqual(before);
} finally {
try {
await legacy?.end();
} finally {
try {
await source.end();
} finally {
try {
await closeRegisteredClients(legacyUrl.href);
} finally {
try {
await database.cleanup();
} finally {
await rm(directory, { recursive: true, force: true });
}
}
}
}
}
},
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
);
it(
"migrates a fresh database and upgrades deployed chat history without replaying chat SQL",
async () => {
const database = await startEmbeddedPostgresTestDatabase(
"paperclip-chat-identity-migration-",
);
const sql = postgres(database.connectionString, {
max: 1,
onnotice: () => {},
});
try {
expect(
(await inspectMigrations(database.connectionString)).status,
).toBe("upToDate");
expect(
(
await sql`SELECT to_regclass('public.run_identity_contexts')::text AS identity, to_regclass('public.chat_publications')::text AS publications`
)[0],
).toEqual({
identity: "run_identity_contexts",
publications: "chat_publications",
});
// Restore the schema/history shape of a deployed pre-identity chat DB.
// This disposable database is owned solely by this test; no live fixture
// is modified. Its already-applied chat SQL retains the original hashes
// and timestamps, even though the checkout now uses new filenames.
await sql`ALTER TABLE heartbeat_runs DROP COLUMN active_identity_context_id`;
await sql`ALTER TABLE issues DROP COLUMN origin_identity_context_id, DROP COLUMN continuation_identity_context_id`;
await sql`ALTER TABLE issue_thread_interactions DROP COLUMN source_identity_context_id`;
await sql`DROP TABLE run_identity_contexts`;
for (const file of identityMigrations) {
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${await migrationHash(file)}`;
}
for (const [, hash, timestamp] of chatMigrations) {
await sql`UPDATE drizzle.__drizzle_migrations SET created_at = ${timestamp} WHERE hash = ${hash}`;
}
const companyId = randomUUID(),
agentId = randomUUID(),
applicationId = randomUUID(),
connectionId = randomUUID(),
endpointId = randomUUID(),
issueId = randomUUID(),
conversationId = randomUUID(),
publicationId = randomUUID();
await sql`INSERT INTO companies (id,name,issue_prefix) VALUES (${companyId},'Chat upgrade','CUP')`;
await sql`INSERT INTO agents (id,company_id,name) VALUES (${agentId},${companyId},'Chat agent')`;
await sql`INSERT INTO tool_applications (id,company_id,name,type) VALUES (${applicationId},${companyId},'Slack','rest_api')`;
await sql`INSERT INTO tool_connections (id,company_id,application_id,name,uid,connection_purpose,transport) VALUES (${connectionId},${companyId},${applicationId},'Slack','slack-upgrade','channel','chat_sdk')`;
await sql`INSERT INTO chat_endpoints (id,company_id,connection_id,provider,public_id,assigned_agent_id) VALUES (${endpointId},${companyId},${connectionId},'slack',${randomUUID()},${agentId})`;
await sql`INSERT INTO issues (id,company_id,title) VALUES (${issueId},${companyId},'Existing chat task')`;
await sql`INSERT INTO chat_conversations (id,company_id,endpoint_id,issue_id,external_conversation_id,external_thread_id,external_label) VALUES (${conversationId},${companyId},${endpointId},${issueId},'CUPGRADE','slack:CUPGRADE:1700.1','Existing Slack thread')`;
await sql`INSERT INTO chat_publications (id,company_id,endpoint_id,conversation_id,issue_id,idempotency_key,payload,state,attempts) VALUES (${publicationId},${companyId},${endpointId},${conversationId},${issueId},'existing-unknown-file','{"attachmentIds":["existing-attachment"]}'::jsonb,'delivery_unknown',1)`;
const rowsBefore =
await sql`SELECT row_to_json(p) AS row FROM chat_publications p WHERE id = ${publicationId}`;
const historyBefore =
await sql`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations ORDER BY id`;
expect(
await inspectMigrations(database.connectionString),
).toMatchObject({
status: "needsMigrations",
pendingMigrations: identityMigrations,
});
await applyPendingMigrations(database.connectionString);
expect(
(await inspectMigrations(database.connectionString)).status,
).toBe("upToDate");
expect(
await sql`SELECT row_to_json(p) AS row FROM chat_publications p WHERE id = ${publicationId}`,
).toEqual(rowsBefore);
expect(
await sql`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations WHERE id <= ${historyBefore.at(-1)!.id} ORDER BY id`,
).toEqual(historyBefore);
expect(
(
await sql`SELECT count(*)::integer AS count FROM drizzle.__drizzle_migrations`
)[0].count,
).toBe(historyBefore.length + identityMigrations.length);
expect(
(
await sql`SELECT origin_identity_context_id,continuation_identity_context_id FROM issues WHERE id = ${issueId}`
)[0],
).toEqual({
origin_identity_context_id: null,
continuation_identity_context_id: null,
});
const historyAfter =
await sql`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations ORDER BY id`;
await applyPendingMigrations(database.connectionString);
expect(
await sql`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations ORDER BY id`,
).toEqual(historyAfter);
} finally {
await sql.end();
await database.cleanup();
}
},
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
);
},
);