| ... | @@ -20,6 +20,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -20,6 +20,7 @@ import * as Layer from "effect/Layer"; |
| 20 | import * as Path from "effect/Path"; | 20 | import * as Path from "effect/Path"; |
| 21 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 21 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 22 | | 22 | |
| | 23 | import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts"; |
| 23 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; | 24 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; |
| 24 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; | 25 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; |
| 25 | import { | 26 | import { |
| ... | @@ -27,6 +28,7 @@ import { | ... | @@ -27,6 +28,7 @@ import { |
| 27 | SqlitePersistenceMemory, | 28 | SqlitePersistenceMemory, |
| 28 | } from "../../persistence/Layers/Sqlite.ts"; | 29 | } from "../../persistence/Layers/Sqlite.ts"; |
| 29 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; | 30 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| | 31 | import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts"; |
| 30 | import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; | 32 | import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; |
| 31 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 33 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 32 | import { | 34 | import { |
| ... | @@ -58,6 +60,54 @@ const exists = (filePath: string) => | ... | @@ -58,6 +60,54 @@ const exists = (filePath: string) => |
| 58 | | 60 | |
| 59 | const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-"); | 61 | const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-"); |
| 60 | | 62 | |
| | 63 | it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-cursor-batch-")))( |
| | 64 | "OrchestrationProjectionPipeline cursor batches", |
| | 65 | (it) => { |
| | 66 | it.effect("writes a project and all projector cursors in two statements", () => |
| | 67 | Effect.gen(function* () { |
| | 68 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
| | 69 | const eventStore = yield* OrchestrationEventStore; |
| | 70 | const projectionState = yield* ProjectionStateRepository; |
| | 71 | const counter = makeSqlStatementCounter(); |
| | 72 | const createdAt = "2026-01-01T00:00:00.000Z"; |
| | 73 | const event = yield* eventStore.append({ |
| | 74 | type: "project.created", |
| | 75 | eventId: EventId.make("evt-cursor-batch-project"), |
| | 76 | aggregateKind: "project", |
| | 77 | aggregateId: ProjectId.make("project-cursor-batch"), |
| | 78 | occurredAt: createdAt, |
| | 79 | commandId: CommandId.make("cmd-cursor-batch-project"), |
| | 80 | causationEventId: null, |
| | 81 | correlationId: null, |
| | 82 | metadata: {}, |
| | 83 | payload: { |
| | 84 | projectId: ProjectId.make("project-cursor-batch"), |
| | 85 | title: "Cursor batch project", |
| | 86 | workspaceRoot: "/tmp/project-cursor-batch", |
| | 87 | defaultModelSelection: null, |
| | 88 | scripts: [], |
| | 89 | createdAt, |
| | 90 | updatedAt: createdAt, |
| | 91 | }, |
| | 92 | }); |
| | 93 | |
| | 94 | yield* projectionPipeline.projectEvent(event).pipe(Effect.withTracer(counter.tracer)); |
| | 95 | assert.strictEqual(counter.count(), 2); |
| | 96 | assert.deepEqual( |
| | 97 | yield* projectionState.listAll(), |
| | 98 | Object.values(ORCHESTRATION_PROJECTOR_NAMES) |
| | 99 | .sort() |
| | 100 | .map((projector) => ({ |
| | 101 | projector, |
| | 102 | lastAppliedSequence: event.sequence, |
| | 103 | updatedAt: createdAt, |
| | 104 | })), |
| | 105 | ); |
| | 106 | }), |
| | 107 | ); |
| | 108 | }, |
| | 109 | ); |
| | 110 | |
| 61 | it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | 111 | it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 62 | it.effect("bootstraps all projection states and writes projection rows", () => | 112 | it.effect("bootstraps all projection states and writes projection rows", () => |
| 63 | Effect.gen(function* () { | 113 | Effect.gen(function* () { |
| ... | @@ -813,6 +863,8 @@ it.layer( | ... | @@ -813,6 +863,8 @@ it.layer( |
| 813 | }, | 863 | }, |
| 814 | }); | 864 | }); |
| 815 | | 865 | |
| | 866 | const projectionState = yield* ProjectionStateRepository; |
| | 867 | const cursorsBeforeFailure = yield* projectionState.listAll(); |
| 816 | yield* sql` | 868 | yield* sql` |
| 817 | CREATE TRIGGER fail_thread_messages_projection_state_update | 869 | CREATE TRIGGER fail_thread_messages_projection_state_update |
| 818 | BEFORE UPDATE ON projection_state | 870 | BEFORE UPDATE ON projection_state |
| ... | @@ -822,39 +874,39 @@ it.layer( | ... | @@ -822,39 +874,39 @@ it.layer( |
| 822 | END; | 874 | END; |
| 823 | `; | 875 | `; |
| 824 | | 876 | |
| 825 | const result = yield* Effect.result( | 877 | const pendingEvent = yield* eventStore.append({ |
| 826 | appendAndProject({ | 878 | type: "thread.message-sent", |
| 827 | type: "thread.message-sent", | 879 | eventId: EventId.make("evt-rollback-3"), |
| 828 | eventId: EventId.make("evt-rollback-3"), | 880 | aggregateKind: "thread", |
| 829 | aggregateKind: "thread", | 881 | aggregateId: ThreadId.make("thread-rollback"), |
| 830 | aggregateId: ThreadId.make("thread-rollback"), | 882 | occurredAt: now, |
| 831 | occurredAt: now, | 883 | commandId: CommandId.make("cmd-rollback-3"), |
| 832 | commandId: CommandId.make("cmd-rollback-3"), | 884 | causationEventId: null, |
| 833 | causationEventId: null, | 885 | correlationId: CorrelationId.make("cmd-rollback-3"), |
| 834 | correlationId: CorrelationId.make("cmd-rollback-3"), | 886 | metadata: {}, |
| 835 | metadata: {}, | 887 | payload: { |
| 836 | payload: { | 888 | threadId: ThreadId.make("thread-rollback"), |
| 837 | threadId: ThreadId.make("thread-rollback"), | 889 | messageId: MessageId.make("message-rollback"), |
| 838 | messageId: MessageId.make("message-rollback"), | 890 | role: "user", |
| 839 | role: "user", | 891 | text: "Rollback me", |
| 840 | text: "Rollback me", | 892 | attachments: [ |
| 841 | attachments: [ | 893 | { |
| 842 | { | 894 | type: "image", |
| 843 | type: "image", | 895 | id: "thread-rollback-att-1", |
| 844 | id: "thread-rollback-att-1", | 896 | name: "rollback.png", |
| 845 | name: "rollback.png", | 897 | mimeType: "image/png", |
| 846 | mimeType: "image/png", | 898 | sizeBytes: 5, |
| 847 | sizeBytes: 5, | 899 | }, |
| 848 | }, | 900 | ], |
| 849 | ], | 901 | turnId: null, |
| 850 | turnId: null, | 902 | streaming: false, |
| 851 | streaming: false, | 903 | createdAt: now, |
| 852 | createdAt: now, | 904 | updatedAt: now, |
| 853 | updatedAt: now, | 905 | }, |
| 854 | }, | 906 | }); |
| 855 | }), | 907 | const result = yield* Effect.result(projectionPipeline.projectEvent(pendingEvent)); |
| 856 | ); | | |
| 857 | assert.equal(result._tag, "Failure"); | 908 | assert.equal(result._tag, "Failure"); |
| | 909 | assert.deepEqual(yield* projectionState.listAll(), cursorsBeforeFailure); |
| 858 | | 910 | |
| 859 | const rows = yield* sql<{ | 911 | const rows = yield* sql<{ |
| 860 | readonly count: number; | 912 | readonly count: number; |
| ... | @@ -869,6 +921,21 @@ it.layer( | ... | @@ -869,6 +921,21 @@ it.layer( |
| 869 | const attachmentPath = path.join(attachmentsDir, "thread-rollback-att-1.png"); | 921 | const attachmentPath = path.join(attachmentsDir, "thread-rollback-att-1.png"); |
| 870 | assert.isFalse(yield* exists(attachmentPath)); | 922 | assert.isFalse(yield* exists(attachmentPath)); |
| 871 | yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`; | 923 | yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`; |
| | 924 | |
| | 925 | yield* projectionPipeline.bootstrap; |
| | 926 | yield* projectionPipeline.bootstrap; |
| | 927 | assert.deepEqual( |
| | 928 | yield* projectionState.listAll(), |
| | 929 | cursorsBeforeFailure.map((cursor) => ({ |
| | 930 | ...cursor, |
| | 931 | lastAppliedSequence: pendingEvent.sequence, |
| | 932 | updatedAt: pendingEvent.occurredAt, |
| | 933 | })), |
| | 934 | ); |
| | 935 | const replayedMessages = yield* sql<{ readonly text: string }>` |
| | 936 | SELECT text FROM projection_thread_messages WHERE message_id = 'message-rollback' |
| | 937 | `; |
| | 938 | assert.deepEqual(replayedMessages, [{ text: "Rollback me" }]); |
| 872 | }), | 939 | }), |
| 873 | ); | 940 | ); |
| 874 | }); | 941 | }); |
| ... | @@ -3888,6 +3955,14 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { | ... | @@ -3888,6 +3955,14 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 3888 | | 3955 | |
| 3889 | yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); | 3956 | yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); |
| 3890 | yield* fileSystem.writeFileString(attachmentPath, "keep this attachment"); | 3957 | yield* fileSystem.writeFileString(attachmentPath, "keep this attachment"); |
| | 3958 | const readCursors = sql<{ |
| | 3959 | readonly projector: string; |
| | 3960 | readonly lastAppliedSequence: number; |
| | 3961 | }>` |
| | 3962 | SELECT projector, last_applied_sequence AS "lastAppliedSequence" |
| | 3963 | FROM projection_state ORDER BY projector |
| | 3964 | `; |
| | 3965 | const cursorsBeforeFailure = yield* readCursors; |
| 3891 | yield* sql` | 3966 | yield* sql` |
| 3892 | CREATE TRIGGER fail_attachment_command_receipt | 3967 | CREATE TRIGGER fail_attachment_command_receipt |
| 3893 | BEFORE INSERT ON orchestration_command_receipts | 3968 | BEFORE INSERT ON orchestration_command_receipts |
| ... | @@ -3899,6 +3974,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { | ... | @@ -3899,6 +3974,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 3899 | const deleteCommand = { type: "thread.delete", commandId, threadId } as const; | 3974 | const deleteCommand = { type: "thread.delete", commandId, threadId } as const; |
| 3900 | const dispatchError = yield* engine.dispatch(deleteCommand).pipe(Effect.flip); | 3975 | const dispatchError = yield* engine.dispatch(deleteCommand).pipe(Effect.flip); |
| 3901 | assert.equal(dispatchError._tag, "PersistenceSqlError"); | 3976 | assert.equal(dispatchError._tag, "PersistenceSqlError"); |
| | 3977 | assert.deepEqual(yield* readCursors, cursorsBeforeFailure); |
| 3902 | assert.equal(yield* fileSystem.readFileString(attachmentPath), "keep this attachment"); | 3978 | assert.equal(yield* fileSystem.readFileString(attachmentPath), "keep this attachment"); |
| 3903 | const rolledBackThreads = yield* sql<{ readonly deletedAt: string | null }>` | 3979 | const rolledBackThreads = yield* sql<{ readonly deletedAt: string | null }>` |
| 3904 | SELECT deleted_at AS "deletedAt" FROM projection_threads WHERE thread_id = ${threadId} | 3980 | SELECT deleted_at AS "deletedAt" FROM projection_threads WHERE thread_id = ${threadId} |
| ... | @@ -3915,6 +3991,13 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { | ... | @@ -3915,6 +3991,13 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 3915 | yield* sql`DROP TRIGGER fail_attachment_command_receipt`; | 3991 | yield* sql`DROP TRIGGER fail_attachment_command_receipt`; |
| 3916 | | 3992 | |
| 3917 | const result = yield* engine.dispatch(deleteCommand); | 3993 | const result = yield* engine.dispatch(deleteCommand); |
| | 3994 | assert.deepEqual( |
| | 3995 | yield* readCursors, |
| | 3996 | cursorsBeforeFailure.map((cursor) => ({ |
| | 3997 | ...cursor, |
| | 3998 | lastAppliedSequence: result.sequence, |
| | 3999 | })), |
| | 4000 | ); |
| 3918 | assert.isFalse(yield* exists(attachmentPath)); | 4001 | assert.isFalse(yield* exists(attachmentPath)); |
| 3919 | const committedReceipts = yield* sql<{ | 4002 | const committedReceipts = yield* sql<{ |
| 3920 | readonly status: string; | 4003 | readonly status: string; |