| ... | ... | @@ -477,146 +477,150 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 477 | 477 | }), |
| 478 | 478 | ); |
| 479 | 479 | |
| 480 | | it.effect("overwrites attachment file bytes when a message updates the same attachment index", () => |
| 481 | | Effect.gen(function* () { |
| 482 | | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
| 483 | | const eventStore = yield* OrchestrationEventStore; |
| 484 | | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")); |
| 485 | | const now = new Date().toISOString(); |
| 486 | | const later = new Date(Date.now() + 1_000).toISOString(); |
| 480 | it.effect( |
| 481 | "overwrites attachment file bytes when a message updates the same attachment index", |
| 482 | () => |
| 483 | Effect.gen(function* () { |
| 484 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
| 485 | const eventStore = yield* OrchestrationEventStore; |
| 486 | const stateDir = fs.mkdtempSync( |
| 487 | path.join(os.tmpdir(), "t3-projection-attachments-overwrite-"), |
| 488 | ); |
| 489 | const now = new Date().toISOString(); |
| 490 | const later = new Date(Date.now() + 1_000).toISOString(); |
| 487 | 491 | |
| 488 | | const serverConfig = { |
| 489 | | mode: "web", |
| 490 | | port: 0, |
| 491 | | host: undefined, |
| 492 | | cwd: "/tmp/project-attachments", |
| 493 | | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), |
| 494 | | stateDir, |
| 495 | | staticDir: undefined, |
| 496 | | devUrl: undefined, |
| 497 | | noBrowser: true, |
| 498 | | authToken: undefined, |
| 499 | | autoBootstrapProjectFromCwd: false, |
| 500 | | logWebSocketEvents: false, |
| 501 | | } satisfies ServerConfigShape; |
| 492 | const serverConfig = { |
| 493 | mode: "web", |
| 494 | port: 0, |
| 495 | host: undefined, |
| 496 | cwd: "/tmp/project-attachments", |
| 497 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), |
| 498 | stateDir, |
| 499 | staticDir: undefined, |
| 500 | devUrl: undefined, |
| 501 | noBrowser: true, |
| 502 | authToken: undefined, |
| 503 | autoBootstrapProjectFromCwd: false, |
| 504 | logWebSocketEvents: false, |
| 505 | } satisfies ServerConfigShape; |
| 502 | 506 | |
| 503 | | yield* eventStore.append({ |
| 504 | | type: "project.created", |
| 505 | | eventId: EventId.makeUnsafe("evt-overwrite-1"), |
| 506 | | aggregateKind: "project", |
| 507 | | aggregateId: ProjectId.makeUnsafe("project-overwrite"), |
| 508 | | occurredAt: now, |
| 509 | | commandId: CommandId.makeUnsafe("cmd-overwrite-1"), |
| 510 | | causationEventId: null, |
| 511 | | correlationId: CommandId.makeUnsafe("cmd-overwrite-1"), |
| 512 | | metadata: {}, |
| 513 | | payload: { |
| 514 | | projectId: ProjectId.makeUnsafe("project-overwrite"), |
| 515 | | title: "Project Overwrite", |
| 516 | | workspaceRoot: "/tmp/project-overwrite", |
| 517 | | defaultModel: null, |
| 518 | | scripts: [], |
| 519 | | createdAt: now, |
| 520 | | updatedAt: now, |
| 521 | | }, |
| 522 | | }); |
| 523 | | |
| 524 | | yield* eventStore.append({ |
| 525 | | type: "thread.created", |
| 526 | | eventId: EventId.makeUnsafe("evt-overwrite-2"), |
| 527 | | aggregateKind: "thread", |
| 528 | | aggregateId: ThreadId.makeUnsafe("thread-overwrite"), |
| 529 | | occurredAt: now, |
| 530 | | commandId: CommandId.makeUnsafe("cmd-overwrite-2"), |
| 531 | | causationEventId: null, |
| 532 | | correlationId: CommandId.makeUnsafe("cmd-overwrite-2"), |
| 533 | | metadata: {}, |
| 534 | | payload: { |
| 535 | | threadId: ThreadId.makeUnsafe("thread-overwrite"), |
| 536 | | projectId: ProjectId.makeUnsafe("project-overwrite"), |
| 537 | | title: "Thread Overwrite", |
| 538 | | model: "gpt-5-codex", |
| 539 | | branch: null, |
| 540 | | worktreePath: null, |
| 541 | | createdAt: now, |
| 542 | | updatedAt: now, |
| 543 | | }, |
| 544 | | }); |
| 507 | yield* eventStore.append({ |
| 508 | type: "project.created", |
| 509 | eventId: EventId.makeUnsafe("evt-overwrite-1"), |
| 510 | aggregateKind: "project", |
| 511 | aggregateId: ProjectId.makeUnsafe("project-overwrite"), |
| 512 | occurredAt: now, |
| 513 | commandId: CommandId.makeUnsafe("cmd-overwrite-1"), |
| 514 | causationEventId: null, |
| 515 | correlationId: CommandId.makeUnsafe("cmd-overwrite-1"), |
| 516 | metadata: {}, |
| 517 | payload: { |
| 518 | projectId: ProjectId.makeUnsafe("project-overwrite"), |
| 519 | title: "Project Overwrite", |
| 520 | workspaceRoot: "/tmp/project-overwrite", |
| 521 | defaultModel: null, |
| 522 | scripts: [], |
| 523 | createdAt: now, |
| 524 | updatedAt: now, |
| 525 | }, |
| 526 | }); |
| 545 | 527 | |
| 546 | | yield* eventStore.append({ |
| 547 | | type: "thread.message-sent", |
| 548 | | eventId: EventId.makeUnsafe("evt-overwrite-3"), |
| 549 | | aggregateKind: "thread", |
| 550 | | aggregateId: ThreadId.makeUnsafe("thread-overwrite"), |
| 551 | | occurredAt: now, |
| 552 | | commandId: CommandId.makeUnsafe("cmd-overwrite-3"), |
| 553 | | causationEventId: null, |
| 554 | | correlationId: CommandId.makeUnsafe("cmd-overwrite-3"), |
| 555 | | metadata: {}, |
| 556 | | payload: { |
| 557 | | threadId: ThreadId.makeUnsafe("thread-overwrite"), |
| 558 | | messageId: MessageId.makeUnsafe("message-overwrite"), |
| 559 | | role: "user", |
| 560 | | text: "first image", |
| 561 | | attachments: [ |
| 562 | | { |
| 563 | | type: "image", |
| 564 | | name: "file.png", |
| 565 | | mimeType: "image/png", |
| 566 | | sizeBytes: 5, |
| 567 | | dataUrl: "data:image/png;base64,SGVsbG8=", |
| 568 | | }, |
| 569 | | ], |
| 570 | | turnId: null, |
| 571 | | streaming: false, |
| 572 | | createdAt: now, |
| 573 | | updatedAt: now, |
| 574 | | }, |
| 575 | | }); |
| 528 | yield* eventStore.append({ |
| 529 | type: "thread.created", |
| 530 | eventId: EventId.makeUnsafe("evt-overwrite-2"), |
| 531 | aggregateKind: "thread", |
| 532 | aggregateId: ThreadId.makeUnsafe("thread-overwrite"), |
| 533 | occurredAt: now, |
| 534 | commandId: CommandId.makeUnsafe("cmd-overwrite-2"), |
| 535 | causationEventId: null, |
| 536 | correlationId: CommandId.makeUnsafe("cmd-overwrite-2"), |
| 537 | metadata: {}, |
| 538 | payload: { |
| 539 | threadId: ThreadId.makeUnsafe("thread-overwrite"), |
| 540 | projectId: ProjectId.makeUnsafe("project-overwrite"), |
| 541 | title: "Thread Overwrite", |
| 542 | model: "gpt-5-codex", |
| 543 | branch: null, |
| 544 | worktreePath: null, |
| 545 | createdAt: now, |
| 546 | updatedAt: now, |
| 547 | }, |
| 548 | }); |
| 576 | 549 | |
| 577 | | yield* eventStore.append({ |
| 578 | | type: "thread.message-sent", |
| 579 | | eventId: EventId.makeUnsafe("evt-overwrite-4"), |
| 580 | | aggregateKind: "thread", |
| 581 | | aggregateId: ThreadId.makeUnsafe("thread-overwrite"), |
| 582 | | occurredAt: later, |
| 583 | | commandId: CommandId.makeUnsafe("cmd-overwrite-4"), |
| 584 | | causationEventId: null, |
| 585 | | correlationId: CommandId.makeUnsafe("cmd-overwrite-4"), |
| 586 | | metadata: {}, |
| 587 | | payload: { |
| 588 | | threadId: ThreadId.makeUnsafe("thread-overwrite"), |
| 589 | | messageId: MessageId.makeUnsafe("message-overwrite"), |
| 590 | | role: "user", |
| 591 | | text: "", |
| 592 | | attachments: [ |
| 593 | | { |
| 594 | | type: "image", |
| 595 | | name: "file.png", |
| 596 | | mimeType: "image/png", |
| 597 | | sizeBytes: 5, |
| 598 | | dataUrl: "data:image/png;base64,V29ybGQ=", |
| 599 | | }, |
| 600 | | ], |
| 601 | | turnId: null, |
| 602 | | streaming: false, |
| 603 | | createdAt: now, |
| 604 | | updatedAt: later, |
| 605 | | }, |
| 606 | | }); |
| 550 | yield* eventStore.append({ |
| 551 | type: "thread.message-sent", |
| 552 | eventId: EventId.makeUnsafe("evt-overwrite-3"), |
| 553 | aggregateKind: "thread", |
| 554 | aggregateId: ThreadId.makeUnsafe("thread-overwrite"), |
| 555 | occurredAt: now, |
| 556 | commandId: CommandId.makeUnsafe("cmd-overwrite-3"), |
| 557 | causationEventId: null, |
| 558 | correlationId: CommandId.makeUnsafe("cmd-overwrite-3"), |
| 559 | metadata: {}, |
| 560 | payload: { |
| 561 | threadId: ThreadId.makeUnsafe("thread-overwrite"), |
| 562 | messageId: MessageId.makeUnsafe("message-overwrite"), |
| 563 | role: "user", |
| 564 | text: "first image", |
| 565 | attachments: [ |
| 566 | { |
| 567 | type: "image", |
| 568 | name: "file.png", |
| 569 | mimeType: "image/png", |
| 570 | sizeBytes: 5, |
| 571 | dataUrl: "data:image/png;base64,SGVsbG8=", |
| 572 | }, |
| 573 | ], |
| 574 | turnId: null, |
| 575 | streaming: false, |
| 576 | createdAt: now, |
| 577 | updatedAt: now, |
| 578 | }, |
| 579 | }); |
| 607 | 580 | |
| 608 | | yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig)); |
| 581 | yield* eventStore.append({ |
| 582 | type: "thread.message-sent", |
| 583 | eventId: EventId.makeUnsafe("evt-overwrite-4"), |
| 584 | aggregateKind: "thread", |
| 585 | aggregateId: ThreadId.makeUnsafe("thread-overwrite"), |
| 586 | occurredAt: later, |
| 587 | commandId: CommandId.makeUnsafe("cmd-overwrite-4"), |
| 588 | causationEventId: null, |
| 589 | correlationId: CommandId.makeUnsafe("cmd-overwrite-4"), |
| 590 | metadata: {}, |
| 591 | payload: { |
| 592 | threadId: ThreadId.makeUnsafe("thread-overwrite"), |
| 593 | messageId: MessageId.makeUnsafe("message-overwrite"), |
| 594 | role: "user", |
| 595 | text: "", |
| 596 | attachments: [ |
| 597 | { |
| 598 | type: "image", |
| 599 | name: "file.png", |
| 600 | mimeType: "image/png", |
| 601 | sizeBytes: 5, |
| 602 | dataUrl: "data:image/png;base64,V29ybGQ=", |
| 603 | }, |
| 604 | ], |
| 605 | turnId: null, |
| 606 | streaming: false, |
| 607 | createdAt: now, |
| 608 | updatedAt: later, |
| 609 | }, |
| 610 | }); |
| 609 | 611 | |
| 610 | | const attachmentPath = path.join( |
| 611 | | stateDir, |
| 612 | | "attachments", |
| 613 | | "thread-overwrite", |
| 614 | | "message-overwrite-0.png", |
| 615 | | ); |
| 616 | | assert.equal(fs.existsSync(attachmentPath), true); |
| 617 | | assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("V29ybGQ=", "base64")); |
| 618 | | fs.rmSync(stateDir, { recursive: true, force: true }); |
| 619 | | }), |
| 612 | yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig)); |
| 613 | |
| 614 | const attachmentPath = path.join( |
| 615 | stateDir, |
| 616 | "attachments", |
| 617 | "thread-overwrite", |
| 618 | "message-overwrite-0.png", |
| 619 | ); |
| 620 | assert.equal(fs.existsSync(attachmentPath), true); |
| 621 | assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("V29ybGQ=", "base64")); |
| 622 | fs.rmSync(stateDir, { recursive: true, force: true }); |
| 623 | }), |
| 620 | 624 | ); |
| 621 | 625 | |
| 622 | 626 | it.effect("does not persist attachment files when projector transaction rolls back", () => |
| ... | ... | @@ -624,7 +628,9 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 624 | 628 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
| 625 | 629 | const eventStore = yield* OrchestrationEventStore; |
| 626 | 630 | const sql = yield* SqlClient.SqlClient; |
| 627 | | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-rollback-")); |
| 631 | const stateDir = fs.mkdtempSync( |
| 632 | path.join(os.tmpdir(), "t3-projection-attachments-rollback-"), |
| 633 | ); |
| 628 | 634 | const now = new Date().toISOString(); |
| 629 | 635 | |
| 630 | 636 | const serverConfig = { |
| ... | ... | @@ -643,12 +649,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 643 | 649 | } satisfies ServerConfigShape; |
| 644 | 650 | |
| 645 | 651 | const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) => |
| 646 | | eventStore |
| 647 | | .append(event) |
| 648 | | .pipe( |
| 649 | | Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)), |
| 650 | | Effect.provideService(ServerConfig, serverConfig), |
| 651 | | ); |
| 652 | eventStore.append(event).pipe( |
| 653 | Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)), |
| 654 | Effect.provideService(ServerConfig, serverConfig), |
| 655 | ); |
| 652 | 656 | |
| 653 | 657 | yield* appendAndProject({ |
| 654 | 658 | type: "project.created", |
| ... | ... | @@ -780,12 +784,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 780 | 784 | } satisfies ServerConfigShape; |
| 781 | 785 | |
| 782 | 786 | const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) => |
| 783 | | eventStore |
| 784 | | .append(event) |
| 785 | | .pipe( |
| 786 | | Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)), |
| 787 | | Effect.provideService(ServerConfig, serverConfig), |
| 788 | | ); |
| 787 | eventStore.append(event).pipe( |
| 788 | Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)), |
| 789 | Effect.provideService(ServerConfig, serverConfig), |
| 790 | ); |
| 789 | 791 | |
| 790 | 792 | yield* appendAndProject({ |
| 791 | 793 | type: "project.created", |
| ... | ... | @@ -996,12 +998,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 996 | 998 | } satisfies ServerConfigShape; |
| 997 | 999 | |
| 998 | 1000 | const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) => |
| 999 | | eventStore |
| 1000 | | .append(event) |
| 1001 | | .pipe( |
| 1002 | | Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)), |
| 1003 | | Effect.provideService(ServerConfig, serverConfig), |
| 1004 | | ); |
| 1001 | eventStore.append(event).pipe( |
| 1002 | Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)), |
| 1003 | Effect.provideService(ServerConfig, serverConfig), |
| 1004 | ); |
| 1005 | 1005 | |
| 1006 | 1006 | yield* appendAndProject({ |
| 1007 | 1007 | type: "project.created", |
| ... | ... | @@ -1101,6 +1101,59 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 1101 | 1101 | }), |
| 1102 | 1102 | ); |
| 1103 | 1103 | |
| 1104 | it.effect("ignores unsafe thread ids for attachment cleanup paths", () => |
| 1105 | Effect.gen(function* () { |
| 1106 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
| 1107 | const eventStore = yield* OrchestrationEventStore; |
| 1108 | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-unsafe-")); |
| 1109 | const now = new Date().toISOString(); |
| 1110 | const attachmentsRootDir = path.join(stateDir, "attachments"); |
| 1111 | const attachmentsSentinelPath = path.join(attachmentsRootDir, "sentinel.txt"); |
| 1112 | const stateDirSentinelPath = path.join(stateDir, "state-sentinel.txt"); |
| 1113 | fs.mkdirSync(attachmentsRootDir, { recursive: true }); |
| 1114 | fs.writeFileSync(attachmentsSentinelPath, "keep-attachments-root", "utf8"); |
| 1115 | fs.writeFileSync(stateDirSentinelPath, "keep-state-dir", "utf8"); |
| 1116 | |
| 1117 | const serverConfig = { |
| 1118 | mode: "web", |
| 1119 | port: 0, |
| 1120 | host: undefined, |
| 1121 | cwd: "/tmp/project-attachments", |
| 1122 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), |
| 1123 | stateDir, |
| 1124 | staticDir: undefined, |
| 1125 | devUrl: undefined, |
| 1126 | noBrowser: true, |
| 1127 | authToken: undefined, |
| 1128 | autoBootstrapProjectFromCwd: false, |
| 1129 | logWebSocketEvents: false, |
| 1130 | } satisfies ServerConfigShape; |
| 1131 | |
| 1132 | yield* eventStore.append({ |
| 1133 | type: "thread.deleted", |
| 1134 | eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"), |
| 1135 | aggregateKind: "thread", |
| 1136 | aggregateId: ThreadId.makeUnsafe(".."), |
| 1137 | occurredAt: now, |
| 1138 | commandId: CommandId.makeUnsafe("cmd-unsafe-thread-delete"), |
| 1139 | causationEventId: null, |
| 1140 | correlationId: CorrelationId.makeUnsafe("cmd-unsafe-thread-delete"), |
| 1141 | metadata: {}, |
| 1142 | payload: { |
| 1143 | threadId: ThreadId.makeUnsafe(".."), |
| 1144 | deletedAt: now, |
| 1145 | }, |
| 1146 | }); |
| 1147 | |
| 1148 | yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig)); |
| 1149 | |
| 1150 | assert.equal(fs.existsSync(attachmentsRootDir), true); |
| 1151 | assert.equal(fs.existsSync(attachmentsSentinelPath), true); |
| 1152 | assert.equal(fs.existsSync(stateDirSentinelPath), true); |
| 1153 | fs.rmSync(stateDir, { recursive: true, force: true }); |
| 1154 | }), |
| 1155 | ); |
| 1156 | |
| 1104 | 1157 | it.effect("resumes from projector last_applied_sequence without replaying older events", () => |
| 1105 | 1158 | Effect.gen(function* () { |
| 1106 | 1159 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |