22 * Category Loader
33 *
44 * Load data for sync categories.
5+ * Supports both blob-based (config, state, etc.) and per-item (sessions, messages) loading.
56 */
67
8+ import { readFile , readdir , stat } from 'node:fs/promises' ;
9+ import { join , basename } from 'node:path' ;
710import type { SyncCategory , PathConfig , SyncConfig } from '../types/index.js' ;
11+ import type { LocalSyncState } from '../types/sync.js' ;
12+ import type { Tombstone } from '../types/manifest.js' ;
13+ import { shouldUseItemSync } from '../types/manifest.js' ;
814import { getCategoryPaths } from '../types/paths.js' ;
9- import type { CategoryData } from '../sync/operations/types.js' ;
15+ import type { CategoryData , BlobCategoryData , ItemCategoryData } from '../sync/operations/types.js' ;
1016import { loadSinglePath , getPathKey } from './directory-loader.js' ;
17+ import { calculateChecksum } from '../sync/item-packer.js' ;
18+ import { createTombstone , detectLocalDeletions } from '../sync/tombstone.js' ;
1119
1220export interface LoadedData {
1321 categories : CategoryData [ ] ;
@@ -20,12 +28,22 @@ export interface LoadError {
2028 error : Error ;
2129}
2230
31+ export interface LoadOptions {
32+ /** Previous local state for deletion detection */
33+ localState ?: LocalSyncState | null ;
34+ /** Machine ID for tombstone creation */
35+ machineId ?: string ;
36+ /** Tombstone grace period in days (default: 30) */
37+ tombstoneGraceDays ?: number ;
38+ }
39+
2340/**
2441 * Load all data for enabled categories.
2542 */
2643export async function loadLocalData (
2744 pathConfig : PathConfig ,
28- enabledCategories : SyncConfig [ 'sync' ]
45+ enabledCategories : SyncConfig [ 'sync' ] ,
46+ options : LoadOptions = { }
2947) : Promise < LoadedData > {
3048 const categoryPaths = getCategoryPaths ( pathConfig ) ;
3149 const categories : CategoryData [ ] = [ ] ;
@@ -35,7 +53,7 @@ export async function loadLocalData(
3553 if ( ! enabledCategories [ category as SyncCategory ] ) continue ;
3654
3755 try {
38- const data = await loadCategoryData ( category as SyncCategory , paths ) ;
56+ const data = await loadCategoryData ( category as SyncCategory , paths , options ) ;
3957 if ( data ) categories . push ( data ) ;
4058 } catch ( error ) {
4159 const firstPath = paths [ 0 ] ;
@@ -54,11 +72,26 @@ export async function loadLocalData(
5472
5573/**
5674 * Load data for a single category.
75+ * Routes to blob or per-item loading based on category.
5776 */
5877async function loadCategoryData (
5978 category : SyncCategory ,
60- paths : string [ ]
79+ paths : string [ ] ,
80+ options : LoadOptions
6181) : Promise < CategoryData | null > {
82+ if ( shouldUseItemSync ( category ) ) {
83+ return loadItemCategoryData ( category , paths , options ) ;
84+ }
85+ return loadBlobCategoryData ( category , paths ) ;
86+ }
87+
88+ /**
89+ * Load blob-based category data (legacy approach).
90+ */
91+ async function loadBlobCategoryData (
92+ category : SyncCategory ,
93+ paths : string [ ]
94+ ) : Promise < BlobCategoryData | null > {
6295 const categoryData : Record < string , unknown > = { } ;
6396 let hasData = false ;
6497
@@ -73,5 +106,97 @@ async function loadCategoryData(
73106 if ( ! hasData ) return null ;
74107
75108 const isJsonl = category === 'state' ;
76- return { category, data : JSON . stringify ( categoryData ) , isJsonl } ;
109+ return { category, type : 'blob' , data : JSON . stringify ( categoryData ) , isJsonl } ;
110+ }
111+
112+ /**
113+ * Load per-item category data (sessions, messages).
114+ * Each file is loaded as a separate item with its own checksum.
115+ * Also detects locally deleted items and creates tombstones for them.
116+ */
117+ async function loadItemCategoryData (
118+ category : SyncCategory ,
119+ paths : string [ ] ,
120+ options : LoadOptions
121+ ) : Promise < ItemCategoryData | null > {
122+ const items : Record < string , string > = { } ;
123+ const checksums : Record < string , string > = { } ;
124+
125+ for ( const basePath of paths ) {
126+ await loadItemsFromPath ( basePath , '' , items , checksums ) ;
127+ }
128+
129+ // Detect locally deleted items by comparing with previous state
130+ const tombstones : Record < string , Tombstone > = { } ;
131+ const { localState, machineId, tombstoneGraceDays } = options ;
132+ const prevChecksums = localState ?. itemChecksums ;
133+ const prevCategoryChecksums = prevChecksums ? prevChecksums [ category ] : undefined ;
134+ if ( prevCategoryChecksums && machineId ) {
135+ const previousItemIds = new Set ( Object . keys ( prevCategoryChecksums ) ) ;
136+ const currentItemIds = new Set ( Object . keys ( items ) ) ;
137+ const deletedIds = detectLocalDeletions ( currentItemIds , previousItemIds ) ;
138+
139+ for ( const itemId of deletedIds ) {
140+ tombstones [ itemId ] = createTombstone ( itemId , machineId , tombstoneGraceDays ) ;
141+ }
142+ }
143+
144+ // Return null only if no items AND no tombstones
145+ if ( Object . keys ( items ) . length === 0 && Object . keys ( tombstones ) . length === 0 ) {
146+ return null ;
147+ }
148+
149+ const result : ItemCategoryData = { category, type : 'items' , items, checksums } ;
150+ if ( Object . keys ( tombstones ) . length > 0 ) {
151+ result . tombstones = tombstones ;
152+ }
153+ return result ;
154+ }
155+
156+ /**
157+ * Recursively load items from a path.
158+ * @param basePath - The base path being scanned
159+ * @param relativePath - Current relative path from basePath
160+ * @param items - Map to populate with item content
161+ * @param checksums - Map to populate with item checksums
162+ */
163+ async function loadItemsFromPath (
164+ basePath : string ,
165+ relativePath : string ,
166+ items : Record < string , string > ,
167+ checksums : Record < string , string >
168+ ) : Promise < void > {
169+ const fullPath = relativePath ? join ( basePath , relativePath ) : basePath ;
170+
171+ try {
172+ const stats = await stat ( fullPath ) ;
173+
174+ if ( stats . isFile ( ) ) {
175+ // Load file as an item
176+ const content = await readFile ( fullPath , 'utf-8' ) ;
177+ const itemId = getItemId ( basePath , relativePath ) ;
178+ items [ itemId ] = content ;
179+ checksums [ itemId ] = calculateChecksum ( content ) ;
180+ } else if ( stats . isDirectory ( ) ) {
181+ // Recurse into directory
182+ const entries = await readdir ( fullPath , { withFileTypes : true } ) ;
183+ for ( const entry of entries ) {
184+ if ( entry . name . startsWith ( '.' ) ) continue ; // Skip hidden files
185+ const childRelPath = relativePath ? join ( relativePath , entry . name ) : entry . name ;
186+ await loadItemsFromPath ( basePath , childRelPath , items , checksums ) ;
187+ }
188+ }
189+ } catch {
190+ // Path doesn't exist - skip
191+ }
192+ }
193+
194+ /**
195+ * Generate a unique item ID from base path and relative path.
196+ * Example: basePath=/data/storage/session, relativePath=abc123.json → session/abc123.json
197+ */
198+ function getItemId ( basePath : string , relativePath : string ) : string {
199+ const baseDir = basename ( basePath ) ;
200+ if ( ! relativePath ) return baseDir ;
201+ return `${ baseDir } /${ relativePath } ` ;
77202}
0 commit comments