11import {
22 ORCHESTRATION_CACHE_SCHEMA_VERSION ,
33 ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION ,
4+ COMPACT_THREAD_CACHE_SCHEMA_VERSION ,
5+ StoredOrchestrationThreadSnapshot ,
46} from "@t3tools/client-runtime/platform" ;
57import {
68 CommandId ,
79 EnvironmentId ,
810 ProjectId ,
911 ProviderInstanceId ,
12+ RunId ,
1013 ThreadId ,
14+ TurnItemId ,
1115 type OrchestrationV2ShellSnapshot ,
1216 type OrchestrationV2ThreadDetailSnapshot ,
1317 type ThreadPullRequestLink ,
@@ -17,6 +21,7 @@ import * as DateTime from "effect/DateTime";
1721import * as Effect from "effect/Effect" ;
1822import * as Logger from "effect/Logger" ;
1923import * as Option from "effect/Option" ;
24+ import * as Schema from "effect/Schema" ;
2025
2126import * as MobileDatabase from "../persistence/mobile-database" ;
2227import { make } from "./environment-cache-store" ;
@@ -26,6 +31,9 @@ const PROJECT_ID = ProjectId.make("project-1");
2631const THREAD_ID = ThreadId . make ( "thread-1" ) ;
2732const PROVIDER_INSTANCE_ID = ProviderInstanceId . make ( "codex" ) ;
2833const NOW = DateTime . makeUnsafe ( "2026-07-29T12:00:00.000Z" ) ;
34+ const encodeLegacyThreadSnapshot = Schema . encodeEffect (
35+ Schema . fromJsonString ( StoredOrchestrationThreadSnapshot ) ,
36+ ) ;
2937const SHELL_SNAPSHOT : OrchestrationV2ShellSnapshot = {
3038 schemaVersion : 1 ,
3139 snapshotSequence : 3 ,
@@ -152,6 +160,59 @@ function cacheId(
152160 return `${ environmentId } :${ kind } :${ cacheKey } ` ;
153161}
154162
163+ function threadSnapshotWithItems ( ) : OrchestrationV2ThreadDetailSnapshot {
164+ const local = {
165+ id : TurnItemId . make ( "local-notice" ) ,
166+ threadId : THREAD_ID ,
167+ runId : null ,
168+ nodeId : null ,
169+ providerThreadId : null ,
170+ providerTurnId : null ,
171+ nativeItemRef : null ,
172+ parentItemId : null ,
173+ ordinal : 10 ,
174+ status : "completed" as const ,
175+ title : null ,
176+ startedAt : NOW ,
177+ completedAt : NOW ,
178+ updatedAt : NOW ,
179+ type : "system_notice" as const ,
180+ message : "A settled local notice." ,
181+ } ;
182+ const hidden = { ...local , id : TurnItemId . make ( "hidden-notice" ) , ordinal : 11 } ;
183+ const inherited = {
184+ ...local ,
185+ id : TurnItemId . make ( "inherited-notice" ) ,
186+ threadId : ThreadId . make ( "parent-thread" ) ,
187+ } ;
188+ return {
189+ ...THREAD_SNAPSHOT ,
190+ historyCursor : "older-page" ,
191+ hasMoreHistory : true ,
192+ latestLocalTurnOrdinal : 11 ,
193+ projection : {
194+ ...THREAD_SNAPSHOT . projection ,
195+ turnItems : [ local , hidden ] ,
196+ visibleTurnItems : [
197+ {
198+ position : 0 ,
199+ visibility : "inherited" ,
200+ sourceThreadId : inherited . threadId ,
201+ sourceItemId : inherited . id ,
202+ item : inherited ,
203+ } ,
204+ {
205+ position : 1 ,
206+ visibility : "local" ,
207+ sourceThreadId : THREAD_ID ,
208+ sourceItemId : local . id ,
209+ item : local ,
210+ } ,
211+ ] ,
212+ } ,
213+ } ;
214+ }
215+
155216function makeDatabase ( ) {
156217 const values = new Map < string , string > ( ) ;
157218 const schemaVersions = new Map < string , number > ( ) ;
@@ -198,6 +259,238 @@ function makeDatabase() {
198259}
199260
200261describe ( "mobile SQLite environment cache store" , ( ) => {
262+ it . effect ( "loads a V5 record and migrates to V6 on the next save without losing items" , ( ) =>
263+ Effect . gen ( function * ( ) {
264+ const memory = makeDatabase ( ) ;
265+ const store = yield * make ( ) . pipe (
266+ Effect . provideService ( MobileDatabase . MobileDatabase , memory . database ) ,
267+ ) ;
268+ const snapshot = threadSnapshotWithItems ( ) ;
269+ const id = cacheId ( ENVIRONMENT_ID , "thread" , THREAD_ID ) ;
270+ const raw = yield * encodeLegacyThreadSnapshot ( {
271+ schemaVersion : ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION ,
272+ environmentId : ENVIRONMENT_ID ,
273+ threadId : THREAD_ID ,
274+ snapshot,
275+ } ) ;
276+ memory . values . set ( id , raw ) ;
277+ const loaded = Option . getOrThrow ( yield * store . loadThread ( ENVIRONMENT_ID , THREAD_ID ) ) ;
278+ expect ( loaded ) . toEqual ( snapshot ) ;
279+ expect ( memory . values . get ( id ) ) . toBe ( raw ) ;
280+ yield * store . saveThread ( ENVIRONMENT_ID , loaded ) ;
281+ expect ( memory . schemaVersions . get ( id ) ) . toBe ( COMPACT_THREAD_CACHE_SCHEMA_VERSION ) ;
282+ expect ( Option . getOrThrow ( yield * store . loadThread ( ENVIRONMENT_ID , THREAD_ID ) ) ) . toEqual (
283+ snapshot ,
284+ ) ;
285+ expect ( memory . removed ) . toEqual ( [ ] ) ;
286+ } ) ,
287+ ) ;
288+
289+ it . effect ( "discards corrupt or partial thread records so they can be refetched" , ( ) =>
290+ Effect . gen ( function * ( ) {
291+ const memory = makeDatabase ( ) ;
292+ const store = yield * make ( ) . pipe (
293+ Effect . provideService ( MobileDatabase . MobileDatabase , memory . database ) ,
294+ ) ;
295+ const id = cacheId ( ENVIRONMENT_ID , "thread" , THREAD_ID ) ;
296+ yield * store . saveThread ( ENVIRONMENT_ID , threadSnapshotWithItems ( ) ) ;
297+ const raw = memory . values . get ( id ) ! ;
298+ const partial = JSON . parse ( raw ) ;
299+ delete partial . snapshot . projection . visibleTurnItems ;
300+ const invalidItem = JSON . parse ( raw ) ;
301+ delete invalidItem . snapshot . projection . visibleTurnItems [ 1 ] . item . message ;
302+ const corruptions = [
303+ partial ,
304+ invalidItem ,
305+ ...[
306+ ( value : typeof partial ) => {
307+ delete value . snapshot . turnItemCount ;
308+ } ,
309+ ( value : typeof partial ) => {
310+ delete value . snapshot . turnItemsOmitLocalVisible ;
311+ } ,
312+ ( value : typeof partial ) => {
313+ value . snapshot . turnItemsOmitLocalVisible = false ;
314+ } ,
315+ ( value : typeof partial ) => {
316+ value . snapshot . projection . visibleTurnItems . pop ( ) ;
317+ } ,
318+ ( value : typeof partial ) => {
319+ value . snapshot . projection . visibleTurnItems . shift ( ) ;
320+ } ,
321+ ( value : typeof partial ) => {
322+ value . snapshot . projection . turnItems . pop ( ) ;
323+ } ,
324+ ( value : typeof partial ) => {
325+ value . snapshot . turnItemCount = 0 ;
326+ } ,
327+ ( value : typeof partial ) => {
328+ value . snapshot . projection . visibleTurnItems [ 1 ] . sourceItemId = "wrong-item" ;
329+ } ,
330+ ( value : typeof partial ) => {
331+ value . snapshot . projection . visibleTurnItems [ 1 ] . visibility = "inherited" ;
332+ } ,
333+ ( value : typeof partial ) => {
334+ value . snapshot . projection . visibleTurnItems [ 1 ] . item . threadId = "wrong-thread" ;
335+ } ,
336+ ( value : typeof partial ) => {
337+ value . snapshot . projection . thread . id = "wrong-thread" ;
338+ } ,
339+ ( value : typeof partial ) => {
340+ value . snapshot . projection . turnItems . push (
341+ value . snapshot . projection . visibleTurnItems [ 1 ] . item ,
342+ ) ;
343+ } ,
344+ ( value : typeof partial ) => {
345+ value . snapshot . projection . visibleTurnItems . push (
346+ value . snapshot . projection . visibleTurnItems [ 1 ] ,
347+ ) ;
348+ } ,
349+ ] . map ( ( corrupt ) => {
350+ const value = JSON . parse ( raw ) ;
351+ corrupt ( value ) ;
352+ return value ;
353+ } ) ,
354+ ] ;
355+ const invalidRecords = [ "{broken" , ...corruptions . map ( ( value ) => JSON . stringify ( value ) ) ] ;
356+ for ( const invalid of invalidRecords ) {
357+ memory . values . set ( id , invalid ) ;
358+ expect ( yield * store . loadThread ( ENVIRONMENT_ID , THREAD_ID ) ) . toEqual ( Option . none ( ) ) ;
359+ expect ( memory . values . has ( id ) ) . toBe ( false ) ;
360+ }
361+ expect ( memory . removed ) . toEqual ( invalidRecords . map ( ( ) => id ) ) ;
362+ yield * store . saveThread ( ENVIRONMENT_ID , threadSnapshotWithItems ( ) ) ;
363+ expect ( Option . getOrThrow ( yield * store . loadThread ( ENVIRONMENT_ID , THREAD_ID ) ) ) . toEqual (
364+ threadSnapshotWithItems ( ) ,
365+ ) ;
366+ } ) ,
367+ ) ;
368+
369+ it . effect ( "round-trips V5 exactly and restores shared items before returning to a consumer" , ( ) =>
370+ Effect . gen ( function * ( ) {
371+ const memory = makeDatabase ( ) ;
372+ const store = yield * make ( ) . pipe (
373+ Effect . provideService ( MobileDatabase . MobileDatabase , memory . database ) ,
374+ ) ;
375+ const original = threadSnapshotWithItems ( ) ;
376+ const local = original . projection . visibleTurnItems [ 1 ] ! ;
377+ const inherited = original . projection . visibleTurnItems [ 0 ] ! ;
378+ const synthetic = { ...local . item , id : TurnItemId . make ( "synthetic-notice" ) , ordinal : 12 } ;
379+ const fork = {
380+ id : TurnItemId . make ( "fork-marker" ) ,
381+ threadId : THREAD_ID ,
382+ runId : null ,
383+ nodeId : null ,
384+ providerTurnId : null ,
385+ nativeItemRef : null ,
386+ parentItemId : null ,
387+ ordinal : 0 ,
388+ status : "completed" as const ,
389+ title : null ,
390+ startedAt : null ,
391+ completedAt : NOW ,
392+ updatedAt : NOW ,
393+ type : "fork" as const ,
394+ source : {
395+ type : "run" as const ,
396+ threadId : ThreadId . make ( "parent-thread" ) ,
397+ runId : RunId . make ( "parent-run" ) ,
398+ } ,
399+ targetThreadId : THREAD_ID ,
400+ } ;
401+ const snapshot = {
402+ ...original ,
403+ projection : {
404+ ...original . projection ,
405+ turnItems : [ local . item , synthetic , original . projection . turnItems [ 1 ] ! ] ,
406+ visibleTurnItems : [
407+ { ...local , position : 0 } ,
408+ {
409+ ...inherited ,
410+ position : 1 ,
411+ sourceItemId : local . sourceItemId ,
412+ item : { ...inherited . item , id : local . item . id } ,
413+ } ,
414+ {
415+ ...local ,
416+ position : 2 ,
417+ visibility : "synthetic" as const ,
418+ sourceItemId : synthetic . id ,
419+ item : synthetic ,
420+ } ,
421+ {
422+ position : 3 ,
423+ visibility : "synthetic" as const ,
424+ sourceThreadId : fork . source . threadId ,
425+ sourceItemId : fork . id ,
426+ item : fork ,
427+ } ,
428+ ] ,
429+ } ,
430+ } ;
431+ yield * store . saveThread ( ENVIRONMENT_ID , snapshot ) ;
432+ const id = cacheId ( ENVIRONMENT_ID , "thread" , THREAD_ID ) ;
433+ const encoded = JSON . parse ( memory . values . get ( id ) ! ) ;
434+ expect ( encoded . schemaVersion ) . toBe ( 6 ) ;
435+ expect ( memory . schemaVersions . get ( id ) ) . toBe ( 6 ) ;
436+ expect ( encoded . snapshot . turnItemsOmitLocalVisible ) . toBe ( true ) ;
437+ expect ( encoded . snapshot . turnItemCount ) . toBe ( 3 ) ;
438+ expect ( encoded . snapshot . visibleTurnItemCount ) . toBe ( 4 ) ;
439+ expect ( encoded . snapshot . projection . turnItems . map ( ( item : { id : string } ) => item . id ) ) . toEqual ( [
440+ "hidden-notice" ,
441+ ] ) ;
442+
443+ const duplicate = JSON . parse ( memory . values . get ( id ) ! ) ;
444+ duplicate . snapshot . projection . visibleTurnItems . push ( {
445+ ...duplicate . snapshot . projection . visibleTurnItems [ 1 ] ,
446+ position : 4 ,
447+ } ) ;
448+ duplicate . snapshot . visibleTurnItemCount += 1 ;
449+ memory . values . set ( id , JSON . stringify ( duplicate ) ) ;
450+ expect ( yield * store . loadThread ( ENVIRONMENT_ID , THREAD_ID ) ) . toEqual ( Option . none ( ) ) ;
451+ expect ( memory . values . has ( id ) ) . toBe ( false ) ;
452+ expect ( memory . removed ) . toEqual ( [ id ] ) ;
453+ yield * store . saveThread ( ENVIRONMENT_ID , snapshot ) ;
454+
455+ const loaded = Option . getOrThrow ( yield * store . loadThread ( ENVIRONMENT_ID , THREAD_ID ) ) ;
456+ expect ( loaded ) . toEqual ( snapshot ) ;
457+ expect ( loaded ) . not . toHaveProperty ( "turnItemsOmitLocalVisible" ) ;
458+ expect ( loaded ) . not . toHaveProperty ( "turnItemCount" ) ;
459+ expect ( loaded ) . not . toHaveProperty ( "visibleTurnItemCount" ) ;
460+ expect ( loaded . projection . turnItems [ 0 ] ) . toBe ( loaded . projection . visibleTurnItems [ 0 ] ! . item ) ;
461+ expect ( loaded . projection . turnItems [ 1 ] ) . toBe ( loaded . projection . visibleTurnItems [ 2 ] ! . item ) ;
462+ expect ( snapshot . projection . turnItems ) . toHaveLength ( 3 ) ;
463+ } ) ,
464+ ) ;
465+
466+ it . effect ( "keeps a full projection when visible rows cannot reproduce the original items" , ( ) =>
467+ Effect . gen ( function * ( ) {
468+ const memory = makeDatabase ( ) ;
469+ const store = yield * make ( ) . pipe (
470+ Effect . provideService ( MobileDatabase . MobileDatabase , memory . database ) ,
471+ ) ;
472+ const original = threadSnapshotWithItems ( ) ;
473+ const local = original . projection . turnItems [ 0 ] ! ;
474+ for ( const turnItems of [
475+ [ { ...local } , original . projection . turnItems [ 1 ] ! ] ,
476+ [ { ...local , message : "Newer than the visible row." } , original . projection . turnItems [ 1 ] ! ] ,
477+ original . projection . turnItems . toReversed ( ) ,
478+ ] ) {
479+ const snapshot = { ...original , projection : { ...original . projection , turnItems } } ;
480+ yield * store . saveThread ( ENVIRONMENT_ID , snapshot ) ;
481+ const raw = memory . values . get ( cacheId ( ENVIRONMENT_ID , "thread" , THREAD_ID ) ) ! ;
482+ const encoded = JSON . parse ( raw ) ;
483+ expect ( encoded . schemaVersion ) . toBe ( 6 ) ;
484+ expect ( encoded . snapshot . turnItemsOmitLocalVisible ) . toBe ( false ) ;
485+ expect ( encoded . snapshot . turnItemCount ) . toBe ( 2 ) ;
486+ expect ( encoded . snapshot . projection . turnItems ) . toHaveLength ( 2 ) ;
487+ expect ( Option . getOrThrow ( yield * store . loadThread ( ENVIRONMENT_ID , THREAD_ID ) ) ) . toEqual (
488+ snapshot ,
489+ ) ;
490+ }
491+ } ) ,
492+ ) ;
493+
201494 it . effect ( "round-trips V2 shell and thread DateTime fields with the shared cache schema" , ( ) =>
202495 Effect . gen ( function * ( ) {
203496 const memory = makeDatabase ( ) ;
@@ -229,7 +522,7 @@ describe("mobile SQLite environment cache store", () => {
229522 ORCHESTRATION_CACHE_SCHEMA_VERSION ,
230523 ) ;
231524 expect ( memory . schemaVersions . get ( cacheId ( ENVIRONMENT_ID , "thread" , THREAD_ID ) ) ) . toBe (
232- ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION ,
525+ COMPACT_THREAD_CACHE_SCHEMA_VERSION ,
233526 ) ;
234527 } ) ,
235528 ) ;
0 commit comments