@@ -265,6 +265,145 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => {
265265 } ) ,
266266 ) ;
267267
268+ it . effect ( "compacts superseded state events, imported v1 events, and legacy receipts" , ( ) =>
269+ Effect . gen ( function * ( ) {
270+ const eventSink = yield * EventSinkV2 ;
271+ const maintenance = yield * ProjectionMaintenanceV2 ;
272+ const sql = yield * SqlClient . SqlClient ;
273+ const now = yield * DateTime . now ;
274+ const nowIso = DateTime . formatIso ( now ) ;
275+ const threadId = ThreadId . make ( "thread:foundation-compact" ) ;
276+ const thread = makeThread ( threadId , now ) ;
277+ const messageId = MessageId . make ( "message:foundation-compact" ) ;
278+ const threadStateEvent = (
279+ suffix : string ,
280+ type : "thread.visited" | "thread.metadata-updated" ,
281+ ) : OrchestrationV2DomainEvent => ( {
282+ id : EventId . make ( `event:foundation-compact:${ suffix } ` ) ,
283+ type,
284+ threadId,
285+ providerInstanceId,
286+ occurredAt : now ,
287+ payload : { ...thread , lastVisitedAt : now } ,
288+ } ) ;
289+ const messageEvent = ( suffix : string , text : string ) : OrchestrationV2DomainEvent => ( {
290+ id : EventId . make ( `event:foundation-compact:${ suffix } ` ) ,
291+ type : "message.updated" ,
292+ threadId,
293+ occurredAt : now ,
294+ payload : {
295+ createdBy : "user" ,
296+ creationSource : "web" ,
297+ id : messageId ,
298+ threadId,
299+ runId : null ,
300+ nodeId : null ,
301+ role : "user" ,
302+ text,
303+ attachments : [ ] ,
304+ streaming : false ,
305+ createdAt : now ,
306+ updatedAt : now ,
307+ } ,
308+ } ) ;
309+
310+ yield * eventSink . write ( {
311+ events : [
312+ threadCreatedEvent ( { id : "event:foundation-compact:create" , thread, now } ) ,
313+ threadStateEvent ( "meta" , "thread.metadata-updated" ) ,
314+ threadStateEvent ( "visit-1" , "thread.visited" ) ,
315+ threadStateEvent ( "visit-2" , "thread.visited" ) ,
316+ messageEvent ( "message-1" , "streaming" ) ,
317+ messageEvent ( "message-2" , "final" ) ,
318+ ] ,
319+ } ) ;
320+
321+ // A fully imported legacy thread: its v1 events and pre-migration
322+ // receipts are dead weight; a still-pending import keeps its rows.
323+ const importedV1ThreadId = "thread:foundation-compact-v1-imported" ;
324+ const pendingV1ThreadId = "thread:foundation-compact-v1-pending" ;
325+ const insertV1Event = ( threadIdValue : string , version : number ) => sql `
326+ INSERT INTO orchestration_events (
327+ event_id, aggregate_kind, stream_id, stream_version, event_type,
328+ occurred_at, actor_kind, payload_json, metadata_json, application_event_version
329+ )
330+ VALUES (
331+ ${ `event:v1:${ threadIdValue } :${ version } ` } , 'thread', ${ threadIdValue } , ${ version } ,
332+ 'thread.message-appended', ${ nowIso } , 'user', '{}', '{}', 1
333+ )
334+ ` ;
335+ yield * insertV1Event ( importedV1ThreadId , 1 ) ;
336+ yield * insertV1Event ( importedV1ThreadId , 2 ) ;
337+ yield * insertV1Event ( pendingV1ThreadId , 1 ) ;
338+ yield * sql `
339+ INSERT INTO orchestration_v2_legacy_imports (
340+ thread_id, source_updated_at, shell_imported_at, transcript_imported_at,
341+ imported_message_count, last_error
342+ )
343+ VALUES
344+ (${ importedV1ThreadId } , ${ nowIso } , ${ nowIso } , ${ nowIso } , 2, NULL),
345+ (${ pendingV1ThreadId } , ${ nowIso } , ${ nowIso } , NULL, 0, NULL)
346+ ON CONFLICT(thread_id) DO NOTHING
347+ ` ;
348+ yield * sql `
349+ INSERT INTO orchestration_command_receipts (
350+ command_id, aggregate_kind, aggregate_id, accepted_at, result_sequence, status, command_type
351+ )
352+ VALUES
353+ ('command:foundation-compact:legacy', 'thread', ${ importedV1ThreadId } , ${ nowIso } , 1, 'accepted', 'legacy'),
354+ ('command:foundation-compact:pending', 'thread', ${ pendingV1ThreadId } , ${ nowIso } , 1, 'accepted', 'legacy')
355+ ON CONFLICT(command_id) DO NOTHING
356+ ` ;
357+
358+ const summary = yield * maintenance . compactEventStore ;
359+ // meta + visit-1 (superseded thread state), message-1, and the two
360+ // imported v1 events.
361+ assert . isAtLeast ( summary . deletedEventCount , 5 ) ;
362+ assert . isAtLeast ( summary . deletedReceiptCount , 1 ) ;
363+
364+ const remaining = yield * sql < { readonly event_id : string } > `
365+ SELECT event_id
366+ FROM orchestration_events
367+ WHERE aggregate_kind = 'thread'
368+ AND stream_id = ${ threadId }
369+ ORDER BY sequence ASC
370+ ` ;
371+ assert . deepEqual (
372+ remaining . map ( ( row ) => row . event_id ) ,
373+ [
374+ "event:foundation-compact:create" ,
375+ "event:foundation-compact:visit-2" ,
376+ "event:foundation-compact:message-2" ,
377+ ] ,
378+ ) ;
379+
380+ const remainingV1 = yield * sql < { readonly stream_id : string } > `
381+ SELECT stream_id
382+ FROM orchestration_events
383+ WHERE application_event_version = 1
384+ AND stream_id IN (${ importedV1ThreadId } , ${ pendingV1ThreadId } )
385+ ` ;
386+ assert . deepEqual (
387+ remainingV1 . map ( ( row ) => row . stream_id ) ,
388+ [ pendingV1ThreadId ] ,
389+ ) ;
390+
391+ const remainingReceipts = yield * sql < { readonly command_id : string } > `
392+ SELECT command_id
393+ FROM orchestration_command_receipts
394+ WHERE command_id IN ('command:foundation-compact:legacy', 'command:foundation-compact:pending')
395+ ` ;
396+ assert . deepEqual (
397+ remainingReceipts . map ( ( row ) => row . command_id ) ,
398+ [ "command:foundation-compact:pending" ] ,
399+ ) ;
400+
401+ // Replay across the deletion gaps must still produce a valid projection.
402+ assert . isTrue ( ( yield * maintenance . verify ) . valid ) ;
403+ assert . isTrue ( ( yield * maintenance . rebuild ) . valid ) ;
404+ } ) ,
405+ ) ;
406+
268407 it . effect ( "verifies and rebuilds projections with cross-thread subagent relations" , ( ) =>
269408 Effect . gen ( function * ( ) {
270409 const eventSink = yield * EventSinkV2 ;
0 commit comments