| author | |
| committer | |
| log | 76208f79de4cb79146f1501feed6162eef71e24b |
| tree | 4cfe207a23788cbdaf4fc0886a06e16b27da93b1 |
| parent | 9e2324c2e567d75f37b305fbaf53b545178db5b0 |
9 files changed, 678 insertions(+), 35 deletions(-)
.plans/16c-pr89-remediation-checklist.md+12-14| ... | ... | @@ -11,7 +11,7 @@ Status values: |
| 11 | 11 | - `DONE`: Implemented and verified |
| 12 | 12 | - `CLOSED_INVALID`: Stale/invalid review finding |
| 13 | 13 | |
| 14 | Counts: active `52` (`valid=33`, `partially-valid=19`), closed-invalid `5` | |
| 14 | Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6` | |
| 15 | 15 | |
| 16 | 16 | ## Active Checklist |
| 17 | 17 | |
| ... | ... | @@ -172,17 +172,8 @@ Counts: active `52` (`valid=33`, `partially-valid=19`), closed-invalid `5` |
| 172 | 172 | - Threads: PRRT_kwDORLtfbc5whtrM |
| 173 | 173 | - Audit note: Persisted event can outpace in-memory projection on mid-flight failure. |
| 174 | 174 | |
| 175 | - [ ] `C014` Engine error handler catches all errors including non-invariant ones | |
| 176 | - Status: `TODO` | |
| 177 | - Verdict: `partially-valid` | |
| 178 | - Severity: `Medium` | |
| 179 | - Area: `Event ordering and state consistency` | |
| 180 | - File: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts:144` | |
| 181 | - Threads: PRRT_kwDORLtfbc5wkPaJ | |
| 182 | - Audit note: Broad catch may be intentional for liveness; correctness concern is contextual. | |
| 183 | ||
| 184 | - [ ] `C015` The gap-filling fallback logic can retain messages from turns that are about to be deleted, causing foreign key violations. Consider removing the fallback logic entirely, or filtering `fallbackUserMessages` and `fallbackAssistantMessages` to only include messages whose `turnId` is in `retainedTurnIds`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 185 | - Status: `TODO` | |
| 175 | - [x] `C015` The gap-filling fallback logic can retain messages from turns that are about to be deleted, causing foreign key violations. Consider removing the fallback logic entirely, or filtering `fallbackUserMessages` and `fallbackAssistantMessages` to only include messages whose `turnId` is in `retainedTurnIds`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 176 | - Status: `DONE` | |
| 186 | 177 | - Verdict: `partially-valid` |
| 187 | 178 | - Severity: `Medium` |
| 188 | 179 | - Area: `Event ordering and state consistency` |
| ... | ... | @@ -190,8 +181,8 @@ Counts: active `52` (`valid=33`, `partially-valid=19`), closed-invalid `5` |
| 190 | 181 | - Threads: PRRT_kwDORLtfbc5whxJO |
| 191 | 182 | - Audit note: Message fallback retention issue is real, but prior FK-violation claim is overstated. |
| 192 | 183 | |
| 193 | - [ ] `C016` The in-memory `pendingTurnStartByThreadId` map isn't restored during bootstrap. If the service restarts after processing `thread.turn-start-requested` but before `thread.session-set`, the `userMessageId` and `startedAt` will be lost since bootstrap resumes *after* the committed sequence. Consider persisting this pending state or processing these two events atomically. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 194 | - Status: `TODO` | |
| 184 | - [x] `C016` The in-memory `pendingTurnStartByThreadId` map isn't restored during bootstrap. If the service restarts after processing `thread.turn-start-requested` but before `thread.session-set`, the `userMessageId` and `startedAt` will be lost since bootstrap resumes *after* the committed sequence. Consider persisting this pending state or processing these two events atomically. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 185 | - Status: `DONE` | |
| 195 | 186 | - Verdict: `valid` |
| 196 | 187 | - Severity: `Medium` |
| 197 | 188 | - Area: `Event ordering and state consistency` |
| ... | ... | @@ -506,6 +497,13 @@ Counts: active `52` (`valid=33`, `partially-valid=19`), closed-invalid `5` |
| 506 | 497 | |
| 507 | 498 | ## Closed Invalid Items |
| 508 | 499 | |
| 500 | - [x] `C014` Engine error handler catches all errors including non-invariant ones | |
| 501 | - Status: `CLOSED_INVALID` | |
| 502 | - Severity: `Medium` | |
| 503 | - File: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts:144` | |
| 504 | - Threads: PRRT_kwDORLtfbc5wkPaJ | |
| 505 | - Rationale: Broad catch is intentional for worker liveness; transactional dispatch path prevents the claimed non-invariant idempotency break in current design. | |
| 506 | ||
| 509 | 507 | - [x] `C021` Shared mutable default metadata object causes stale eventId |
| 510 | 508 | - Status: `CLOSED_INVALID` |
| 511 | 509 | - Severity: `Medium` |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+327-1| ... | ... | @@ -1,18 +1,29 @@ |
| 1 | 1 | import { |
| 2 | CheckpointRef, | |
| 2 | 3 | CommandId, |
| 3 | 4 | CorrelationId, |
| 4 | 5 | EventId, |
| 5 | 6 | MessageId, |
| 7 | ProviderSessionId, | |
| 8 | ProviderThreadId, | |
| 6 | 9 | ProjectId, |
| 7 | 10 | ThreadId, |
| 11 | TurnId, | |
| 8 | 12 | } from "@t3tools/contracts"; |
| 13 | import { NodeServices } from "@effect/platform-node"; | |
| 9 | 14 | import { assert, it } from "@effect/vitest"; |
| 10 | 15 | import { Effect, Layer } from "effect"; |
| 16 | import fs from "node:fs"; | |
| 17 | import os from "node:os"; | |
| 18 | import path from "node:path"; | |
| 11 | 19 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 12 | 20 | |
| 13 | 21 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; |
| 14 | 22 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; |
| 15 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 23 | import { | |
| 24 | makeSqlitePersistenceLive, | |
| 25 | SqlitePersistenceMemory, | |
| 26 | } from "../../persistence/Layers/Sqlite.ts"; | |
| 16 | 27 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| 17 | 28 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 18 | 29 | import { |
| ... | ... | @@ -400,8 +411,323 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 400 | 411 | assert.isFalse(Boolean(messageRows[0]?.isStreaming)); |
| 401 | 412 | }), |
| 402 | 413 | ); |
| 414 | ||
| 415 | it.effect("does not fallback-retain messages whose turnId is removed by revert", () => | |
| 416 | Effect.gen(function* () { | |
| 417 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 418 | const eventStore = yield* OrchestrationEventStore; | |
| 419 | const sql = yield* SqlClient.SqlClient; | |
| 420 | const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) => | |
| 421 | eventStore | |
| 422 | .append(event) | |
| 423 | .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); | |
| 424 | ||
| 425 | yield* appendAndProject({ | |
| 426 | type: "project.created", | |
| 427 | eventId: EventId.makeUnsafe("evt-revert-1"), | |
| 428 | aggregateKind: "project", | |
| 429 | aggregateId: ProjectId.makeUnsafe("project-revert"), | |
| 430 | occurredAt: "2026-02-26T12:00:00.000Z", | |
| 431 | commandId: CommandId.makeUnsafe("cmd-revert-1"), | |
| 432 | causationEventId: null, | |
| 433 | correlationId: CorrelationId.makeUnsafe("cmd-revert-1"), | |
| 434 | metadata: {}, | |
| 435 | payload: { | |
| 436 | projectId: ProjectId.makeUnsafe("project-revert"), | |
| 437 | title: "Project Revert", | |
| 438 | workspaceRoot: "/tmp/project-revert", | |
| 439 | defaultModel: null, | |
| 440 | scripts: [], | |
| 441 | createdAt: "2026-02-26T12:00:00.000Z", | |
| 442 | updatedAt: "2026-02-26T12:00:00.000Z", | |
| 443 | }, | |
| 444 | }); | |
| 445 | ||
| 446 | yield* appendAndProject({ | |
| 447 | type: "thread.created", | |
| 448 | eventId: EventId.makeUnsafe("evt-revert-2"), | |
| 449 | aggregateKind: "thread", | |
| 450 | aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 451 | occurredAt: "2026-02-26T12:00:01.000Z", | |
| 452 | commandId: CommandId.makeUnsafe("cmd-revert-2"), | |
| 453 | causationEventId: null, | |
| 454 | correlationId: CorrelationId.makeUnsafe("cmd-revert-2"), | |
| 455 | metadata: {}, | |
| 456 | payload: { | |
| 457 | threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 458 | projectId: ProjectId.makeUnsafe("project-revert"), | |
| 459 | title: "Thread Revert", | |
| 460 | model: "gpt-5-codex", | |
| 461 | branch: null, | |
| 462 | worktreePath: null, | |
| 463 | createdAt: "2026-02-26T12:00:01.000Z", | |
| 464 | updatedAt: "2026-02-26T12:00:01.000Z", | |
| 465 | }, | |
| 466 | }); | |
| 467 | ||
| 468 | yield* appendAndProject({ | |
| 469 | type: "thread.turn-diff-completed", | |
| 470 | eventId: EventId.makeUnsafe("evt-revert-3"), | |
| 471 | aggregateKind: "thread", | |
| 472 | aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 473 | occurredAt: "2026-02-26T12:00:02.000Z", | |
| 474 | commandId: CommandId.makeUnsafe("cmd-revert-3"), | |
| 475 | causationEventId: null, | |
| 476 | correlationId: CorrelationId.makeUnsafe("cmd-revert-3"), | |
| 477 | metadata: {}, | |
| 478 | payload: { | |
| 479 | threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 480 | turnId: TurnId.makeUnsafe("turn-1"), | |
| 481 | checkpointTurnCount: 1, | |
| 482 | checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert/turn/1"), | |
| 483 | status: "ready", | |
| 484 | files: [], | |
| 485 | assistantMessageId: MessageId.makeUnsafe("assistant-keep"), | |
| 486 | completedAt: "2026-02-26T12:00:02.000Z", | |
| 487 | }, | |
| 488 | }); | |
| 489 | ||
| 490 | yield* appendAndProject({ | |
| 491 | type: "thread.message-sent", | |
| 492 | eventId: EventId.makeUnsafe("evt-revert-4"), | |
| 493 | aggregateKind: "thread", | |
| 494 | aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 495 | occurredAt: "2026-02-26T12:00:02.100Z", | |
| 496 | commandId: CommandId.makeUnsafe("cmd-revert-4"), | |
| 497 | causationEventId: null, | |
| 498 | correlationId: CorrelationId.makeUnsafe("cmd-revert-4"), | |
| 499 | metadata: {}, | |
| 500 | payload: { | |
| 501 | threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 502 | messageId: MessageId.makeUnsafe("assistant-keep"), | |
| 503 | role: "assistant", | |
| 504 | text: "kept", | |
| 505 | turnId: TurnId.makeUnsafe("turn-1"), | |
| 506 | streaming: false, | |
| 507 | createdAt: "2026-02-26T12:00:02.100Z", | |
| 508 | updatedAt: "2026-02-26T12:00:02.100Z", | |
| 509 | }, | |
| 510 | }); | |
| 511 | ||
| 512 | yield* appendAndProject({ | |
| 513 | type: "thread.turn-diff-completed", | |
| 514 | eventId: EventId.makeUnsafe("evt-revert-5"), | |
| 515 | aggregateKind: "thread", | |
| 516 | aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 517 | occurredAt: "2026-02-26T12:00:03.000Z", | |
| 518 | commandId: CommandId.makeUnsafe("cmd-revert-5"), | |
| 519 | causationEventId: null, | |
| 520 | correlationId: CorrelationId.makeUnsafe("cmd-revert-5"), | |
| 521 | metadata: {}, | |
| 522 | payload: { | |
| 523 | threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 524 | turnId: TurnId.makeUnsafe("turn-2"), | |
| 525 | checkpointTurnCount: 2, | |
| 526 | checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert/turn/2"), | |
| 527 | status: "ready", | |
| 528 | files: [], | |
| 529 | assistantMessageId: MessageId.makeUnsafe("assistant-remove"), | |
| 530 | completedAt: "2026-02-26T12:00:03.000Z", | |
| 531 | }, | |
| 532 | }); | |
| 533 | ||
| 534 | yield* appendAndProject({ | |
| 535 | type: "thread.message-sent", | |
| 536 | eventId: EventId.makeUnsafe("evt-revert-6"), | |
| 537 | aggregateKind: "thread", | |
| 538 | aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 539 | occurredAt: "2026-02-26T12:00:03.050Z", | |
| 540 | commandId: CommandId.makeUnsafe("cmd-revert-6"), | |
| 541 | causationEventId: null, | |
| 542 | correlationId: CorrelationId.makeUnsafe("cmd-revert-6"), | |
| 543 | metadata: {}, | |
| 544 | payload: { | |
| 545 | threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 546 | messageId: MessageId.makeUnsafe("user-remove"), | |
| 547 | role: "user", | |
| 548 | text: "removed", | |
| 549 | turnId: TurnId.makeUnsafe("turn-2"), | |
| 550 | streaming: false, | |
| 551 | createdAt: "2026-02-26T12:00:03.050Z", | |
| 552 | updatedAt: "2026-02-26T12:00:03.050Z", | |
| 553 | }, | |
| 554 | }); | |
| 555 | ||
| 556 | yield* appendAndProject({ | |
| 557 | type: "thread.message-sent", | |
| 558 | eventId: EventId.makeUnsafe("evt-revert-7"), | |
| 559 | aggregateKind: "thread", | |
| 560 | aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 561 | occurredAt: "2026-02-26T12:00:03.100Z", | |
| 562 | commandId: CommandId.makeUnsafe("cmd-revert-7"), | |
| 563 | causationEventId: null, | |
| 564 | correlationId: CorrelationId.makeUnsafe("cmd-revert-7"), | |
| 565 | metadata: {}, | |
| 566 | payload: { | |
| 567 | threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 568 | messageId: MessageId.makeUnsafe("assistant-remove"), | |
| 569 | role: "assistant", | |
| 570 | text: "removed", | |
| 571 | turnId: TurnId.makeUnsafe("turn-2"), | |
| 572 | streaming: false, | |
| 573 | createdAt: "2026-02-26T12:00:03.100Z", | |
| 574 | updatedAt: "2026-02-26T12:00:03.100Z", | |
| 575 | }, | |
| 576 | }); | |
| 577 | ||
| 578 | yield* appendAndProject({ | |
| 579 | type: "thread.reverted", | |
| 580 | eventId: EventId.makeUnsafe("evt-revert-8"), | |
| 581 | aggregateKind: "thread", | |
| 582 | aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 583 | occurredAt: "2026-02-26T12:00:04.000Z", | |
| 584 | commandId: CommandId.makeUnsafe("cmd-revert-8"), | |
| 585 | causationEventId: null, | |
| 586 | correlationId: CorrelationId.makeUnsafe("cmd-revert-8"), | |
| 587 | metadata: {}, | |
| 588 | payload: { | |
| 589 | threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 590 | turnCount: 1, | |
| 591 | }, | |
| 592 | }); | |
| 593 | ||
| 594 | const messageRows = yield* sql<{ | |
| 595 | readonly messageId: string; | |
| 596 | readonly turnId: string | null; | |
| 597 | readonly role: string; | |
| 598 | }>` | |
| 599 | SELECT | |
| 600 | message_id AS "messageId", | |
| 601 | turn_id AS "turnId", | |
| 602 | role | |
| 603 | FROM projection_thread_messages | |
| 604 | WHERE thread_id = 'thread-revert' | |
| 605 | ORDER BY created_at ASC, message_id ASC | |
| 606 | `; | |
| 607 | assert.deepEqual(messageRows, [ | |
| 608 | { | |
| 609 | messageId: "assistant-keep", | |
| 610 | turnId: "turn-1", | |
| 611 | role: "assistant", | |
| 612 | }, | |
| 613 | ]); | |
| 614 | }), | |
| 615 | ); | |
| 616 | ||
| 403 | 617 | }); |
| 404 | 618 | |
| 619 | it.effect("restores pending turn-start metadata across projection pipeline restart", () => | |
| 620 | Effect.gen(function* () { | |
| 621 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-pipeline-restart-")); | |
| 622 | const dbPath = path.join(tempDir, "orchestration.sqlite"); | |
| 623 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); | |
| 624 | const firstProjectionLayer = OrchestrationProjectionPipelineLive.pipe( | |
| 625 | Layer.provideMerge(OrchestrationEventStoreLive), | |
| 626 | Layer.provideMerge(persistenceLayer), | |
| 627 | ); | |
| 628 | const secondProjectionLayer = OrchestrationProjectionPipelineLive.pipe( | |
| 629 | Layer.provideMerge(OrchestrationEventStoreLive), | |
| 630 | Layer.provideMerge(persistenceLayer), | |
| 631 | ); | |
| 632 | ||
| 633 | const threadId = ThreadId.makeUnsafe("thread-restart"); | |
| 634 | const turnId = TurnId.makeUnsafe("turn-restart"); | |
| 635 | const messageId = MessageId.makeUnsafe("message-restart"); | |
| 636 | const turnStartedAt = "2026-02-26T14:00:00.000Z"; | |
| 637 | const sessionSetAt = "2026-02-26T14:00:05.000Z"; | |
| 638 | ||
| 639 | yield* Effect.gen(function* () { | |
| 640 | const eventStore = yield* OrchestrationEventStore; | |
| 641 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 642 | ||
| 643 | yield* eventStore.append({ | |
| 644 | type: "thread.turn-start-requested", | |
| 645 | eventId: EventId.makeUnsafe("evt-restart-1"), | |
| 646 | aggregateKind: "thread", | |
| 647 | aggregateId: threadId, | |
| 648 | occurredAt: turnStartedAt, | |
| 649 | commandId: CommandId.makeUnsafe("cmd-restart-1"), | |
| 650 | causationEventId: null, | |
| 651 | correlationId: CorrelationId.makeUnsafe("cmd-restart-1"), | |
| 652 | metadata: {}, | |
| 653 | payload: { | |
| 654 | threadId, | |
| 655 | messageId, | |
| 656 | createdAt: turnStartedAt, | |
| 657 | }, | |
| 658 | }); | |
| 659 | ||
| 660 | yield* projectionPipeline.bootstrap; | |
| 661 | }).pipe(Effect.provide(firstProjectionLayer)); | |
| 662 | ||
| 663 | const turnRows = yield* Effect.gen(function* () { | |
| 664 | const eventStore = yield* OrchestrationEventStore; | |
| 665 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 666 | const sql = yield* SqlClient.SqlClient; | |
| 667 | ||
| 668 | yield* eventStore.append({ | |
| 669 | type: "thread.session-set", | |
| 670 | eventId: EventId.makeUnsafe("evt-restart-2"), | |
| 671 | aggregateKind: "thread", | |
| 672 | aggregateId: threadId, | |
| 673 | occurredAt: sessionSetAt, | |
| 674 | commandId: CommandId.makeUnsafe("cmd-restart-2"), | |
| 675 | causationEventId: null, | |
| 676 | correlationId: CorrelationId.makeUnsafe("cmd-restart-2"), | |
| 677 | metadata: { | |
| 678 | providerSessionId: ProviderSessionId.makeUnsafe("provider-session-restart"), | |
| 679 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-restart"), | |
| 680 | }, | |
| 681 | payload: { | |
| 682 | threadId, | |
| 683 | session: { | |
| 684 | threadId, | |
| 685 | status: "running", | |
| 686 | providerName: "codex", | |
| 687 | providerSessionId: ProviderSessionId.makeUnsafe("provider-session-restart"), | |
| 688 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-restart"), | |
| 689 | activeTurnId: turnId, | |
| 690 | lastError: null, | |
| 691 | updatedAt: sessionSetAt, | |
| 692 | }, | |
| 693 | }, | |
| 694 | }); | |
| 695 | ||
| 696 | yield* projectionPipeline.bootstrap; | |
| 697 | ||
| 698 | const pendingRows = yield* sql<{ readonly threadId: string }>` | |
| 699 | SELECT thread_id AS "threadId" | |
| 700 | FROM projection_pending_turn_starts | |
| 701 | WHERE thread_id = ${threadId} | |
| 702 | `; | |
| 703 | assert.deepEqual(pendingRows, []); | |
| 704 | ||
| 705 | return yield* sql<{ | |
| 706 | readonly turnId: string; | |
| 707 | readonly userMessageId: string | null; | |
| 708 | readonly startedAt: string; | |
| 709 | }>` | |
| 710 | SELECT | |
| 711 | turn_id AS "turnId", | |
| 712 | user_message_id AS "userMessageId", | |
| 713 | started_at AS "startedAt" | |
| 714 | FROM projection_thread_turns | |
| 715 | WHERE turn_id = ${turnId} | |
| 716 | `; | |
| 717 | }).pipe(Effect.provide(secondProjectionLayer)); | |
| 718 | ||
| 719 | assert.deepEqual(turnRows, [ | |
| 720 | { | |
| 721 | turnId: "turn-restart", | |
| 722 | userMessageId: "message-restart", | |
| 723 | startedAt: turnStartedAt, | |
| 724 | }, | |
| 725 | ]); | |
| 726 | ||
| 727 | fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 728 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 729 | ); | |
| 730 | ||
| 405 | 731 | const engineLayer = it.layer( |
| 406 | 732 | OrchestrationEngineLive.pipe( |
| 407 | 733 | Layer.provide(OrchestrationProjectionPipelineLive), |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+29-18| ... | ... | @@ -6,6 +6,7 @@ import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../per |
| 6 | 6 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| 7 | 7 | import { ProjectionCheckpointRepository } from "../../persistence/Services/ProjectionCheckpoints.ts"; |
| 8 | 8 | import { ProjectionPendingApprovalRepository } from "../../persistence/Services/ProjectionPendingApprovals.ts"; |
| 9 | import { ProjectionPendingTurnStartRepository } from "../../persistence/Services/ProjectionPendingTurnStarts.ts"; | |
| 9 | 10 | import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts"; |
| 10 | 11 | import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts"; |
| 11 | 12 | import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts"; |
| ... | ... | @@ -20,6 +21,7 @@ import { ProjectionThreadTurnRepository } from "../../persistence/Services/Proje |
| 20 | 21 | import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts"; |
| 21 | 22 | import { ProjectionCheckpointRepositoryLive } from "../../persistence/Layers/ProjectionCheckpoints.ts"; |
| 22 | 23 | import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts"; |
| 24 | import { ProjectionPendingTurnStartRepositoryLive } from "../../persistence/Layers/ProjectionPendingTurnStarts.ts"; | |
| 23 | 25 | import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts"; |
| 24 | 26 | import { ProjectionStateRepositoryLive } from "../../persistence/Layers/ProjectionState.ts"; |
| 25 | 27 | import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts"; |
| ... | ... | @@ -97,7 +99,12 @@ function retainProjectionMessagesAfterRevert( |
| 97 | 99 | const missingUserCount = Math.max(0, turnCount - retainedUserCount); |
| 98 | 100 | if (missingUserCount > 0) { |
| 99 | 101 | const fallbackUserMessages = messages |
| 100 | .filter((message) => message.role === "user" && !retainedMessageIds.has(message.messageId)) | |
| 102 | .filter( | |
| 103 | (message) => | |
| 104 | message.role === "user" && | |
| 105 | !retainedMessageIds.has(message.messageId) && | |
| 106 | (message.turnId === null || retainedTurnIds.has(message.turnId)), | |
| 107 | ) | |
| 101 | 108 | .toSorted( |
| 102 | 109 | (left, right) => |
| 103 | 110 | left.createdAt.localeCompare(right.createdAt) || |
| ... | ... | @@ -116,7 +123,10 @@ function retainProjectionMessagesAfterRevert( |
| 116 | 123 | if (missingAssistantCount > 0) { |
| 117 | 124 | const fallbackAssistantMessages = messages |
| 118 | 125 | .filter( |
| 119 | (message) => message.role === "assistant" && !retainedMessageIds.has(message.messageId), | |
| 126 | (message) => | |
| 127 | message.role === "assistant" && | |
| 128 | !retainedMessageIds.has(message.messageId) && | |
| 129 | (message.turnId === null || retainedTurnIds.has(message.turnId)), | |
| 120 | 130 | ) |
| 121 | 131 | .toSorted( |
| 122 | 132 | (left, right) => |
| ... | ... | @@ -157,13 +167,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 157 | 167 | const projectionThreadTurnRepository = yield* ProjectionThreadTurnRepository; |
| 158 | 168 | const projectionCheckpointRepository = yield* ProjectionCheckpointRepository; |
| 159 | 169 | const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; |
| 160 | const pendingTurnStartByThreadId = new Map< | |
| 161 | string, | |
| 162 | { | |
| 163 | readonly messageId: NonNullable<ProjectionThreadTurn["userMessageId"]>; | |
| 164 | readonly createdAt: string; | |
| 165 | } | |
| 166 | >(); | |
| 170 | const projectionPendingTurnStartRepository = yield* ProjectionPendingTurnStartRepository; | |
| 167 | 171 | |
| 168 | 172 | const applyProjectsProjection: ProjectorDefinition["apply"] = (event) => |
| 169 | 173 | Effect.gen(function* () { |
| ... | ... | @@ -473,7 +477,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 473 | 477 | Effect.gen(function* () { |
| 474 | 478 | switch (event.type) { |
| 475 | 479 | case "thread.turn-start-requested": { |
| 476 | pendingTurnStartByThreadId.set(event.payload.threadId, { | |
| 480 | yield* projectionPendingTurnStartRepository.upsert({ | |
| 481 | threadId: event.payload.threadId, | |
| 477 | 482 | messageId: event.payload.messageId, |
| 478 | 483 | createdAt: event.payload.createdAt, |
| 479 | 484 | }); |
| ... | ... | @@ -487,13 +492,16 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 487 | 492 | } |
| 488 | 493 | |
| 489 | 494 | const existingTurn = yield* projectionThreadTurnRepository.getByTurnId({ turnId }); |
| 490 | const pendingTurnStart = pendingTurnStartByThreadId.get(event.payload.threadId); | |
| 495 | const pendingTurnStart = yield* projectionPendingTurnStartRepository.getByThreadId({ | |
| 496 | threadId: event.payload.threadId, | |
| 497 | }); | |
| 491 | 498 | if (Option.isSome(existingTurn)) { |
| 492 | 499 | yield* projectionThreadTurnRepository.upsert({ |
| 493 | 500 | ...existingTurn.value, |
| 494 | 501 | status: existingTurn.value.status === "completed" ? "completed" : "running", |
| 495 | 502 | userMessageId: |
| 496 | existingTurn.value.userMessageId ?? pendingTurnStart?.messageId ?? null, | |
| 503 | existingTurn.value.userMessageId ?? | |
| 504 | (Option.isSome(pendingTurnStart) ? pendingTurnStart.value.messageId : null), | |
| 497 | 505 | startedAt: existingTurn.value.startedAt, |
| 498 | 506 | }); |
| 499 | 507 | } else { |
| ... | ... | @@ -505,14 +513,18 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 505 | 513 | threadId: event.payload.threadId, |
| 506 | 514 | turnCount: nextTurnCount(existingThreadTurns), |
| 507 | 515 | status: "running", |
| 508 | userMessageId: pendingTurnStart?.messageId ?? null, | |
| 516 | userMessageId: Option.isSome(pendingTurnStart) ? pendingTurnStart.value.messageId : null, | |
| 509 | 517 | assistantMessageId: null, |
| 510 | startedAt: pendingTurnStart?.createdAt ?? event.occurredAt, | |
| 518 | startedAt: Option.isSome(pendingTurnStart) | |
| 519 | ? pendingTurnStart.value.createdAt | |
| 520 | : event.occurredAt, | |
| 511 | 521 | completedAt: null, |
| 512 | 522 | }); |
| 513 | 523 | } |
| 514 | 524 | |
| 515 | pendingTurnStartByThreadId.delete(event.payload.threadId); | |
| 525 | yield* projectionPendingTurnStartRepository.deleteByThreadId({ | |
| 526 | threadId: event.payload.threadId, | |
| 527 | }); | |
| 516 | 528 | return; |
| 517 | 529 | } |
| 518 | 530 | |
| ... | ... | @@ -837,9 +849,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 837 | 849 | const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach( |
| 838 | 850 | projectors, |
| 839 | 851 | bootstrapProjector, |
| 840 | { | |
| 841 | concurrency: 1, | |
| 842 | }, | |
| 852 | { concurrency: 1 }, | |
| 843 | 853 | ).pipe( |
| 844 | 854 | Effect.asVoid, |
| 845 | 855 | Effect.tap(() => |
| ... | ... | @@ -870,5 +880,6 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( |
| 870 | 880 | Layer.provideMerge(ProjectionThreadTurnRepositoryLive), |
| 871 | 881 | Layer.provideMerge(ProjectionCheckpointRepositoryLive), |
| 872 | 882 | Layer.provideMerge(ProjectionPendingApprovalRepositoryLive), |
| 883 | Layer.provideMerge(ProjectionPendingTurnStartRepositoryLive), | |
| 873 | 884 | Layer.provideMerge(ProjectionStateRepositoryLive), |
| 874 | 885 | ); |
apps/server/src/orchestration/projector.test.ts+149| ... | ... | @@ -502,4 +502,153 @@ describe("orchestration projector", () => { |
| 502 | 502 | expect(thread?.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount)).toEqual([1]); |
| 503 | 503 | expect(thread?.latestTurnId).toBe("turn-1"); |
| 504 | 504 | }); |
| 505 | ||
| 506 | it("does not fallback-retain messages tied to removed turn IDs", async () => { | |
| 507 | const createdAt = "2026-02-26T12:00:00.000Z"; | |
| 508 | const model = createEmptyReadModel(createdAt); | |
| 509 | ||
| 510 | const afterCreate = await Effect.runPromise( | |
| 511 | projectEvent( | |
| 512 | model, | |
| 513 | makeEvent({ | |
| 514 | sequence: 1, | |
| 515 | type: "thread.created", | |
| 516 | aggregateKind: "thread", | |
| 517 | aggregateId: "thread-revert", | |
| 518 | occurredAt: createdAt, | |
| 519 | commandId: "cmd-create-revert", | |
| 520 | payload: { | |
| 521 | threadId: "thread-revert", | |
| 522 | projectId: "project-1", | |
| 523 | title: "demo", | |
| 524 | model: "gpt-5.3-codex", | |
| 525 | branch: null, | |
| 526 | worktreePath: null, | |
| 527 | createdAt, | |
| 528 | updatedAt: createdAt, | |
| 529 | }, | |
| 530 | }), | |
| 531 | ), | |
| 532 | ); | |
| 533 | ||
| 534 | const events: ReadonlyArray<OrchestrationEvent> = [ | |
| 535 | makeEvent({ | |
| 536 | sequence: 2, | |
| 537 | type: "thread.turn-diff-completed", | |
| 538 | aggregateKind: "thread", | |
| 539 | aggregateId: "thread-revert", | |
| 540 | occurredAt: "2026-02-26T12:00:01.000Z", | |
| 541 | commandId: "cmd-turn-1", | |
| 542 | payload: { | |
| 543 | threadId: "thread-revert", | |
| 544 | turnId: "turn-1", | |
| 545 | checkpointTurnCount: 1, | |
| 546 | checkpointRef: "refs/t3/checkpoints/thread-revert/turn/1", | |
| 547 | status: "ready", | |
| 548 | files: [], | |
| 549 | assistantMessageId: "assistant-keep", | |
| 550 | completedAt: "2026-02-26T12:00:01.000Z", | |
| 551 | }, | |
| 552 | }), | |
| 553 | makeEvent({ | |
| 554 | sequence: 3, | |
| 555 | type: "thread.message-sent", | |
| 556 | aggregateKind: "thread", | |
| 557 | aggregateId: "thread-revert", | |
| 558 | occurredAt: "2026-02-26T12:00:01.100Z", | |
| 559 | commandId: "cmd-assistant-keep", | |
| 560 | payload: { | |
| 561 | threadId: "thread-revert", | |
| 562 | messageId: "assistant-keep", | |
| 563 | role: "assistant", | |
| 564 | text: "kept", | |
| 565 | turnId: "turn-1", | |
| 566 | streaming: false, | |
| 567 | createdAt: "2026-02-26T12:00:01.100Z", | |
| 568 | updatedAt: "2026-02-26T12:00:01.100Z", | |
| 569 | }, | |
| 570 | }), | |
| 571 | makeEvent({ | |
| 572 | sequence: 4, | |
| 573 | type: "thread.turn-diff-completed", | |
| 574 | aggregateKind: "thread", | |
| 575 | aggregateId: "thread-revert", | |
| 576 | occurredAt: "2026-02-26T12:00:02.000Z", | |
| 577 | commandId: "cmd-turn-2", | |
| 578 | payload: { | |
| 579 | threadId: "thread-revert", | |
| 580 | turnId: "turn-2", | |
| 581 | checkpointTurnCount: 2, | |
| 582 | checkpointRef: "refs/t3/checkpoints/thread-revert/turn/2", | |
| 583 | status: "ready", | |
| 584 | files: [], | |
| 585 | assistantMessageId: "assistant-remove", | |
| 586 | completedAt: "2026-02-26T12:00:02.000Z", | |
| 587 | }, | |
| 588 | }), | |
| 589 | makeEvent({ | |
| 590 | sequence: 5, | |
| 591 | type: "thread.message-sent", | |
| 592 | aggregateKind: "thread", | |
| 593 | aggregateId: "thread-revert", | |
| 594 | occurredAt: "2026-02-26T12:00:02.050Z", | |
| 595 | commandId: "cmd-user-remove", | |
| 596 | payload: { | |
| 597 | threadId: "thread-revert", | |
| 598 | messageId: "user-remove", | |
| 599 | role: "user", | |
| 600 | text: "removed", | |
| 601 | turnId: "turn-2", | |
| 602 | streaming: false, | |
| 603 | createdAt: "2026-02-26T12:00:02.050Z", | |
| 604 | updatedAt: "2026-02-26T12:00:02.050Z", | |
| 605 | }, | |
| 606 | }), | |
| 607 | makeEvent({ | |
| 608 | sequence: 6, | |
| 609 | type: "thread.message-sent", | |
| 610 | aggregateKind: "thread", | |
| 611 | aggregateId: "thread-revert", | |
| 612 | occurredAt: "2026-02-26T12:00:02.100Z", | |
| 613 | commandId: "cmd-assistant-remove", | |
| 614 | payload: { | |
| 615 | threadId: "thread-revert", | |
| 616 | messageId: "assistant-remove", | |
| 617 | role: "assistant", | |
| 618 | text: "removed", | |
| 619 | turnId: "turn-2", | |
| 620 | streaming: false, | |
| 621 | createdAt: "2026-02-26T12:00:02.100Z", | |
| 622 | updatedAt: "2026-02-26T12:00:02.100Z", | |
| 623 | }, | |
| 624 | }), | |
| 625 | makeEvent({ | |
| 626 | sequence: 7, | |
| 627 | type: "thread.reverted", | |
| 628 | aggregateKind: "thread", | |
| 629 | aggregateId: "thread-revert", | |
| 630 | occurredAt: "2026-02-26T12:00:03.000Z", | |
| 631 | commandId: "cmd-revert", | |
| 632 | payload: { | |
| 633 | threadId: "thread-revert", | |
| 634 | turnCount: 1, | |
| 635 | }, | |
| 636 | }), | |
| 637 | ]; | |
| 638 | ||
| 639 | const afterRevert = await events.reduce<Promise<ReturnType<typeof createEmptyReadModel>>>( | |
| 640 | (statePromise, event) => | |
| 641 | statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), | |
| 642 | Promise.resolve(afterCreate), | |
| 643 | ); | |
| 644 | ||
| 645 | const thread = afterRevert.threads[0]; | |
| 646 | expect( | |
| 647 | thread?.messages.map((message) => ({ | |
| 648 | id: message.id, | |
| 649 | role: message.role, | |
| 650 | turnId: message.turnId, | |
| 651 | })), | |
| 652 | ).toEqual([{ id: "assistant-keep", role: "assistant", turnId: "turn-1" }]); | |
| 653 | }); | |
| 505 | 654 | }); |
apps/server/src/orchestration/projector.ts+12-2| ... | ... | @@ -65,7 +65,12 @@ function retainThreadMessagesAfterRevert( |
| 65 | 65 | const missingUserCount = Math.max(0, turnCount - retainedUserCount); |
| 66 | 66 | if (missingUserCount > 0) { |
| 67 | 67 | const fallbackUserMessages = messages |
| 68 | .filter((message) => message.role === "user" && !retainedMessageIds.has(message.id)) | |
| 68 | .filter( | |
| 69 | (message) => | |
| 70 | message.role === "user" && | |
| 71 | !retainedMessageIds.has(message.id) && | |
| 72 | (message.turnId === null || retainedTurnIds.has(message.turnId)), | |
| 73 | ) | |
| 69 | 74 | .toSorted( |
| 70 | 75 | (left, right) => |
| 71 | 76 | left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), |
| ... | ... | @@ -82,7 +87,12 @@ function retainThreadMessagesAfterRevert( |
| 82 | 87 | const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); |
| 83 | 88 | if (missingAssistantCount > 0) { |
| 84 | 89 | const fallbackAssistantMessages = messages |
| 85 | .filter((message) => message.role === "assistant" && !retainedMessageIds.has(message.id)) | |
| 90 | .filter( | |
| 91 | (message) => | |
| 92 | message.role === "assistant" && | |
| 93 | !retainedMessageIds.has(message.id) && | |
| 94 | (message.turnId === null || retainedTurnIds.has(message.turnId)), | |
| 95 | ) | |
| 86 | 96 | .toSorted( |
| 87 | 97 | (left, right) => |
| 88 | 98 | left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), |
apps/server/src/persistence/Layers/ProjectionPendingTurnStarts.ts created+92| ... | ... | @@ -0,0 +1,92 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import { toPersistenceSqlError } from "../Errors.ts"; | |
| 6 | import { | |
| 7 | DeleteProjectionPendingTurnStartInput, | |
| 8 | GetProjectionPendingTurnStartInput, | |
| 9 | ProjectionPendingTurnStart, | |
| 10 | ProjectionPendingTurnStartRepository, | |
| 11 | type ProjectionPendingTurnStartRepositoryShape, | |
| 12 | } from "../Services/ProjectionPendingTurnStarts.ts"; | |
| 13 | ||
| 14 | const makeProjectionPendingTurnStartRepository = Effect.gen(function* () { | |
| 15 | const sql = yield* SqlClient.SqlClient; | |
| 16 | ||
| 17 | const upsertProjectionPendingTurnStartRow = SqlSchema.void({ | |
| 18 | Request: ProjectionPendingTurnStart, | |
| 19 | execute: (row) => | |
| 20 | sql` | |
| 21 | INSERT INTO projection_pending_turn_starts ( | |
| 22 | thread_id, | |
| 23 | message_id, | |
| 24 | created_at | |
| 25 | ) | |
| 26 | VALUES ( | |
| 27 | ${row.threadId}, | |
| 28 | ${row.messageId}, | |
| 29 | ${row.createdAt} | |
| 30 | ) | |
| 31 | ON CONFLICT (thread_id) | |
| 32 | DO UPDATE SET | |
| 33 | message_id = excluded.message_id, | |
| 34 | created_at = excluded.created_at | |
| 35 | `, | |
| 36 | }); | |
| 37 | ||
| 38 | const getProjectionPendingTurnStartRow = SqlSchema.findOneOption({ | |
| 39 | Request: GetProjectionPendingTurnStartInput, | |
| 40 | Result: ProjectionPendingTurnStart, | |
| 41 | execute: ({ threadId }) => | |
| 42 | sql` | |
| 43 | SELECT | |
| 44 | thread_id AS "threadId", | |
| 45 | message_id AS "messageId", | |
| 46 | created_at AS "createdAt" | |
| 47 | FROM projection_pending_turn_starts | |
| 48 | WHERE thread_id = ${threadId} | |
| 49 | `, | |
| 50 | }); | |
| 51 | ||
| 52 | const deleteProjectionPendingTurnStartRow = SqlSchema.void({ | |
| 53 | Request: DeleteProjectionPendingTurnStartInput, | |
| 54 | execute: ({ threadId }) => | |
| 55 | sql` | |
| 56 | DELETE FROM projection_pending_turn_starts | |
| 57 | WHERE thread_id = ${threadId} | |
| 58 | `, | |
| 59 | }); | |
| 60 | ||
| 61 | const upsert: ProjectionPendingTurnStartRepositoryShape["upsert"] = (row) => | |
| 62 | upsertProjectionPendingTurnStartRow(row).pipe( | |
| 63 | Effect.mapError(toPersistenceSqlError("ProjectionPendingTurnStartRepository.upsert:query")), | |
| 64 | ); | |
| 65 | ||
| 66 | const getByThreadId: ProjectionPendingTurnStartRepositoryShape["getByThreadId"] = (input) => | |
| 67 | getProjectionPendingTurnStartRow(input).pipe( | |
| 68 | Effect.mapError( | |
| 69 | toPersistenceSqlError("ProjectionPendingTurnStartRepository.getByThreadId:query"), | |
| 70 | ), | |
| 71 | ); | |
| 72 | ||
| 73 | const deleteByThreadId: ProjectionPendingTurnStartRepositoryShape["deleteByThreadId"] = ( | |
| 74 | input, | |
| 75 | ) => | |
| 76 | deleteProjectionPendingTurnStartRow(input).pipe( | |
| 77 | Effect.mapError( | |
| 78 | toPersistenceSqlError("ProjectionPendingTurnStartRepository.deleteByThreadId:query"), | |
| 79 | ), | |
| 80 | ); | |
| 81 | ||
| 82 | return { | |
| 83 | upsert, | |
| 84 | getByThreadId, | |
| 85 | deleteByThreadId, | |
| 86 | } satisfies ProjectionPendingTurnStartRepositoryShape; | |
| 87 | }); | |
| 88 | ||
| 89 | export const ProjectionPendingTurnStartRepositoryLive = Layer.effect( | |
| 90 | ProjectionPendingTurnStartRepository, | |
| 91 | makeProjectionPendingTurnStartRepository, | |
| 92 | ); |
apps/server/src/persistence/Migrations.ts+2| ... | ... | @@ -17,6 +17,7 @@ import Migration0002 from "./Migrations/002_OrchestrationCommandReceipts.ts"; |
| 17 | 17 | import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts"; |
| 18 | 18 | import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts"; |
| 19 | 19 | import Migration0005 from "./Migrations/005_Projections.ts"; |
| 20 | import Migration0006 from "./Migrations/006_ProjectionPendingTurnStarts.ts"; | |
| 20 | 21 | |
| 21 | 22 | /** |
| 22 | 23 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -34,6 +35,7 @@ const loader = Migrator.fromRecord({ |
| 34 | 35 | "3_CheckpointDiffBlobs": Migration0003, |
| 35 | 36 | "4_ProviderSessionRuntime": Migration0004, |
| 36 | 37 | "5_Projections": Migration0005, |
| 38 | "6_ProjectionPendingTurnStarts": Migration0006, | |
| 37 | 39 | }); |
| 38 | 40 | |
| 39 | 41 | /** |
apps/server/src/persistence/Migrations/006_ProjectionPendingTurnStarts.ts created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | import { Effect } from "effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | export default Effect.gen(function* () { | |
| 5 | const sql = yield* SqlClient.SqlClient; | |
| 6 | ||
| 7 | yield* sql` | |
| 8 | CREATE TABLE IF NOT EXISTS projection_pending_turn_starts ( | |
| 9 | thread_id TEXT PRIMARY KEY, | |
| 10 | message_id TEXT NOT NULL, | |
| 11 | created_at TEXT NOT NULL | |
| 12 | ) | |
| 13 | `; | |
| 14 | }); |
apps/server/src/persistence/Services/ProjectionPendingTurnStarts.ts created+41| ... | ... | @@ -0,0 +1,41 @@ |
| 1 | import { IsoDateTime, MessageId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { Option, Schema, ServiceMap } from "effect"; | |
| 3 | import type { Effect } from "effect"; | |
| 4 | ||
| 5 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 6 | ||
| 7 | export const ProjectionPendingTurnStart = Schema.Struct({ | |
| 8 | threadId: ThreadId, | |
| 9 | messageId: MessageId, | |
| 10 | createdAt: IsoDateTime, | |
| 11 | }); | |
| 12 | export type ProjectionPendingTurnStart = typeof ProjectionPendingTurnStart.Type; | |
| 13 | ||
| 14 | export const GetProjectionPendingTurnStartInput = Schema.Struct({ | |
| 15 | threadId: ThreadId, | |
| 16 | }); | |
| 17 | export type GetProjectionPendingTurnStartInput = typeof GetProjectionPendingTurnStartInput.Type; | |
| 18 | ||
| 19 | export const DeleteProjectionPendingTurnStartInput = Schema.Struct({ | |
| 20 | threadId: ThreadId, | |
| 21 | }); | |
| 22 | export type DeleteProjectionPendingTurnStartInput = typeof DeleteProjectionPendingTurnStartInput.Type; | |
| 23 | ||
| 24 | export interface ProjectionPendingTurnStartRepositoryShape { | |
| 25 | readonly upsert: ( | |
| 26 | row: ProjectionPendingTurnStart, | |
| 27 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 28 | ||
| 29 | readonly getByThreadId: ( | |
| 30 | input: GetProjectionPendingTurnStartInput, | |
| 31 | ) => Effect.Effect<Option.Option<ProjectionPendingTurnStart>, ProjectionRepositoryError>; | |
| 32 | ||
| 33 | readonly deleteByThreadId: ( | |
| 34 | input: DeleteProjectionPendingTurnStartInput, | |
| 35 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 36 | } | |
| 37 | ||
| 38 | export class ProjectionPendingTurnStartRepository extends ServiceMap.Service< | |
| 39 | ProjectionPendingTurnStartRepository, | |
| 40 | ProjectionPendingTurnStartRepositoryShape | |
| 41 | >()("persistence/ProjectionPendingTurnStartRepository") {} |