Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions apps/server/src/persistence/Layers/ProjectionRepositories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,62 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
}),
);

it.effect("stores SQL NULL for thread fields omitted by pre-annotation events", () =>
Effect.gen(function* () {
const threads = yield* ProjectionThreadRepository;
const sql = yield* SqlClient.SqlClient;

yield* threads.upsert({
threadId: ThreadId.make("thread-before-annotations"),
projectId: ProjectId.make("project-1"),
title: "Pre-annotation thread",
modelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5.4",
},
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
latestTurnId: null,
createdAt: "2026-03-24T00:00:00.000Z",
updatedAt: "2026-03-24T00:00:00.000Z",
archivedAt: null,
settledOverride: null,
settledAt: null,
snoozedUntil: null,
snoozedAt: null,
pinnedAt: null,
latestUserMessageAt: null,
pendingApprovalCount: 0,
pendingUserInputCount: 0,
hasActionableProposedPlan: 0,
deletedAt: null,
});

const rows = yield* sql<{
readonly annotation: string | null;
readonly latestUserMessageId: string | null;
}>`
SELECT
annotation_json AS annotation,
latest_user_message_id AS "latestUserMessageId"
FROM projection_threads
WHERE thread_id = 'thread-before-annotations'
`;
assert.deepStrictEqual(rows[0], {
annotation: null,
latestUserMessageId: null,
});

const persisted = yield* threads.getById({
threadId: ThreadId.make("thread-before-annotations"),
});
assert.strictEqual(Option.getOrNull(persisted)?.annotation, null);
assert.strictEqual(Option.getOrNull(persisted)?.latestUserMessageId, null);
}),
);

it.effect("round-trips non-null settlement values through the thread row", () =>
Effect.gen(function* () {
const threads = yield* ProjectionThreadRepository;
Expand Down
7 changes: 4 additions & 3 deletions apps/server/src/persistence/Layers/ProjectionThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ListPendingWorktreeCleanupThreadsInput,
ProjectionThread,
ProjectionThreadRepository,
UpsertProjectionThreadInput,
type ProjectionThreadRepositoryShape,
} from "../Services/ProjectionThreads.ts";
import { ModelSelection, ThreadAnnotation, ThreadWorktreeCleanup } from "@t3tools/contracts";
Expand All @@ -32,7 +33,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

const upsertProjectionThreadRow = SqlSchema.void({
Request: ProjectionThread,
Request: UpsertProjectionThreadInput,
execute: (row) =>
sql`
INSERT INTO projection_threads (
Expand Down Expand Up @@ -86,9 +87,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
${row.pinOrderKey ?? null},
${row.titleRegenerationRequestId ?? null},
${row.titleRegenerationStartedAt ?? null},
${row.annotation === null ? null : JSON.stringify(row.annotation)},
${row.annotation == null ? null : JSON.stringify(row.annotation)},
${row.worktreeCleanup == null ? null : JSON.stringify(row.worktreeCleanup)},
${row.latestUserMessageId},
${row.latestUserMessageId ?? null},
${row.latestUserMessageAt},
${row.pendingApprovalCount},
${row.pendingUserInputCount},
Expand Down
24 changes: 14 additions & 10 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,13 @@ import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts";
import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts";
import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts";
import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts";
import Migration0042 from "./Migrations/042_ProjectionThreadAnnotation.ts";
import Migration0043 from "./Migrations/043_UpdateDrain.ts";
import Migration0044 from "./Migrations/044_UpdateDrainClaim.ts";
import Migration0045 from "./Migrations/045_ProjectionTurnRequestCorrelations.ts";
import Migration0046 from "./Migrations/046_ProjectionThreadWorktreeCleanup.ts";
import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts";
import Migration0043 from "./Migrations/043_ProjectionThreadAnnotation.ts";
import Migration0044 from "./Migrations/044_UpdateDrain.ts";
import Migration0045 from "./Migrations/045_UpdateDrainClaim.ts";
import Migration0046 from "./Migrations/046_ProjectionTurnRequestCorrelations.ts";
import Migration0047 from "./Migrations/047_ProjectionThreadWorktreeCleanup.ts";
import Migration0048 from "./Migrations/048_ProjectionThreadLinkedPullRequest.ts";

/**
* Migration loader with all migrations defined inline.
Expand Down Expand Up @@ -112,11 +114,13 @@ export const migrationEntries = [
[39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039],
[40, "ProjectionProjectFaviconPath", Migration0040],
[41, "AuthSessionClientConnection", Migration0041],
[42, "ProjectionThreadAnnotation", Migration0042],
[43, "UpdateDrain", Migration0043],
[44, "UpdateDrainClaim", Migration0044],
[45, "ProjectionTurnRequestCorrelations", Migration0045],
[46, "ProjectionThreadWorktreeCleanup", Migration0046],
[42, "ProjectionThreadLinkedPullRequest", Migration0042],
[43, "ProjectionThreadAnnotation", Migration0043],
[44, "UpdateDrain", Migration0044],
[45, "UpdateDrainClaim", Migration0045],
[46, "ProjectionTurnRequestCorrelations", Migration0046],
[47, "ProjectionThreadWorktreeCleanup", Migration0047],
[48, "ProjectionThreadLinkedPullRequest", Migration0048],
] as const;

export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as SqlClient from "effect/unstable/sql/SqlClient";

import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "../NodeSqliteClient.ts";

const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));

layer("042_ProjectionThreadLinkedPullRequest", (it) => {
it.effect("adds the linked pull request column", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

yield* runMigrations({ toMigrationInclusive: 41 });
yield* runMigrations({ toMigrationInclusive: 42 });

const columns = yield* sql<{ readonly name: string }>`
PRAGMA table_info(projection_threads)
`;
assert.ok(columns.some((column) => column.name === "linked_pull_request_json"));
}),
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as Effect from "effect/Effect";
import * as SqlClient from "effect/unstable/sql/SqlClient";

export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
const columns = yield* sql<{ readonly name: string }>`
PRAGMA table_info(projection_threads)
`;

if (!columns.some((column) => column.name === "linked_pull_request_json")) {
yield* sql`
ALTER TABLE projection_threads
ADD COLUMN linked_pull_request_json TEXT
`;
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import * as NodeSqliteClient from "../NodeSqliteClient.ts";

const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));

layer("042_ProjectionThreadAnnotation", (it) => {
layer("043_ProjectionThreadAnnotation", (it) => {
it.effect("adds annotation and latest user marker fields to thread projections", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

yield* runMigrations({ toMigrationInclusive: 40 });
yield* runMigrations({ toMigrationInclusive: 42 });

yield* sql`
INSERT INTO projection_threads (
Expand Down Expand Up @@ -67,7 +67,7 @@ layer("042_ProjectionThreadAnnotation", (it) => {
'2026-02-24T00:01:00.000Z'
)
`;
yield* runMigrations({ toMigrationInclusive: 42 });
yield* runMigrations({ toMigrationInclusive: 43 });

const columns = yield* sql<{ readonly name: string; readonly notnull: number }>`
PRAGMA table_info(projection_threads)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ import * as NodeSqliteClient from "../NodeSqliteClient.ts";

const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));

layer("043_UpdateDrain", (it) => {
layer("044_UpdateDrain", (it) => {
it.effect("creates a narrow event stream and durable command receipts", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

yield* runMigrations({ toMigrationInclusive: 42 });
yield* runMigrations({ toMigrationInclusive: 43 });
yield* runMigrations({ toMigrationInclusive: 44 });

const eventColumns = yield* sql<{ readonly name: string }>`
PRAGMA table_info(update_drain_events)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import * as NodeSqliteClient from "../NodeSqliteClient.ts";

const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));

layer("044_UpdateDrainClaim", (it) => {
layer("045_UpdateDrainClaim", (it) => {
it.effect("preserves drain history and accepts one claimed transition", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* runMigrations({ toMigrationInclusive: 43 });
yield* runMigrations({ toMigrationInclusive: 44 });
yield* sql`
INSERT INTO update_drain_events (
event_id, event_type, command_id, occurred_at, request_id, target_version, status
Expand All @@ -21,7 +21,7 @@ layer("044_UpdateDrainClaim", (it) => {
'2026-08-21T00:00:00.000Z', 'request-1', '1.2.3', 'draining'
)
`;
yield* runMigrations({ toMigrationInclusive: 44 });
yield* runMigrations({ toMigrationInclusive: 45 });
yield* sql`
INSERT INTO update_drain_events (
event_id, event_type, command_id, occurred_at, request_id, target_version, status
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const layer = it.layer(
),
);

layer("045_ProjectionTurnRequestCorrelations", (it) => {
layer("046_ProjectionTurnRequestCorrelations", (it) => {
it.effect("inserts once, resolves once, and deletes by owning thread", () =>
Effect.gen(function* () {
yield* runMigrations();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import * as NodeSqliteClient from "../NodeSqliteClient.ts";

const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));

layer("046_ProjectionThreadWorktreeCleanup", (it) => {
layer("047_ProjectionThreadWorktreeCleanup", (it) => {
it.effect("adds nullable cleanup state without changing existing rows", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* runMigrations({ toMigrationInclusive: 44 });
yield* runMigrations({ toMigrationInclusive: 46 });
yield* sql`
INSERT INTO projection_threads (
thread_id,
Expand All @@ -36,7 +36,7 @@ layer("046_ProjectionThreadWorktreeCleanup", (it) => {
)
`;

yield* runMigrations({ toMigrationInclusive: 46 });
yield* runMigrations({ toMigrationInclusive: 47 });

const columns = yield* sql<{ readonly name: string; readonly notnull: number }>`
PRAGMA table_info(projection_threads)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as SqlClient from "effect/unstable/sql/SqlClient";

import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "../NodeSqliteClient.ts";
import Migration0043 from "./043_ProjectionThreadAnnotation.ts";
import Migration0044 from "./044_UpdateDrain.ts";
import Migration0045 from "./045_UpdateDrainClaim.ts";
import Migration0046 from "./046_ProjectionTurnRequestCorrelations.ts";
import Migration0047 from "./047_ProjectionThreadWorktreeCleanup.ts";
import Migration0048 from "./048_ProjectionThreadLinkedPullRequest.ts";

const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));

layer("048_ProjectionThreadLinkedPullRequest", (it) => {
it.effect("bridges databases that recorded the previous LastCode migration numbers", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

yield* runMigrations({ toMigrationInclusive: 41 });
yield* Migration0043;
yield* Migration0044;
yield* Migration0045;
yield* Migration0046;
yield* Migration0047;
yield* sql`
INSERT INTO effect_sql_migrations (migration_id, name)
VALUES
(42, 'ProjectionThreadAnnotation'),
(43, 'UpdateDrain'),
(44, 'UpdateDrainClaim'),
(45, 'ProjectionTurnRequestCorrelations'),
(46, 'ProjectionThreadWorktreeCleanup')
`;

const before = yield* sql<{ readonly name: string }>`
PRAGMA table_info(projection_threads)
`;
assert.isFalse(before.some((column) => column.name === "linked_pull_request_json"));

const executed = yield* runMigrations({ toMigrationInclusive: 48 });
assert.deepStrictEqual(executed, [
[47, "ProjectionThreadWorktreeCleanup"],
[48, "ProjectionThreadLinkedPullRequest"],
]);

yield* Migration0048;
const after = yield* sql<{ readonly name: string }>`
PRAGMA table_info(projection_threads)
`;
assert.equal(after.filter((column) => column.name === "linked_pull_request_json").length, 1);
}),
);
});

const partialUpgradeLayer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));

partialUpgradeLayer("048_ProjectionThreadLinkedPullRequest partial upgrades", (it) => {
it.effect("preserves update drain data when upgrading from the previous migration 44", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

yield* runMigrations({ toMigrationInclusive: 41 });
yield* Migration0043;
yield* Migration0044;
yield* Migration0045;
yield* sql`
INSERT INTO update_drain_events (
event_id, event_type, command_id, occurred_at, request_id, target_version, status
) VALUES (
'event-claimed', 'update-drain.claimed', 'command-event',
'2026-08-25T00:00:00.000Z', 'request-1', '1.2.3', 'claimed'
)
`;
yield* sql`
INSERT INTO update_drain_command_receipts (
command_id, command_type, request_id, target_version, accepted_at,
result_sequence, status, error_reason, error
) VALUES (
'command-receipt', 'update-drain.claim', 'request-1', '1.2.3',
'2026-08-25T00:00:01.000Z', 1, 'accepted', NULL, NULL
)
`;
yield* sql`
INSERT INTO effect_sql_migrations (migration_id, name)
VALUES
(42, 'ProjectionThreadAnnotation'),
(43, 'UpdateDrain'),
(44, 'UpdateDrainClaim')
`;

const executed = yield* runMigrations({ toMigrationInclusive: 48 });
assert.deepStrictEqual(executed, [
[45, "UpdateDrainClaim"],
[46, "ProjectionTurnRequestCorrelations"],
[47, "ProjectionThreadWorktreeCleanup"],
[48, "ProjectionThreadLinkedPullRequest"],
]);

const events = yield* sql<{
readonly eventType: string;
readonly status: string;
}>`
SELECT event_type AS "eventType", status
FROM update_drain_events
WHERE event_id = 'event-claimed'
`;
assert.deepStrictEqual(events, [{ eventType: "update-drain.claimed", status: "claimed" }]);

const receipts = yield* sql<{
readonly commandType: string;
readonly status: string;
}>`
SELECT command_type AS "commandType", status
FROM update_drain_command_receipts
WHERE command_id = 'command-receipt'
`;
assert.deepStrictEqual(receipts, [{ commandType: "update-drain.claim", status: "accepted" }]);
}),
);
});
Loading