2025-08-06 12:29:32 -07:00
/ * *
* Metadata Index System
* Maintains inverted indexes for fast metadata filtering
* Automatically updates indexes when data changes
* /
import { StorageAdapter } from '../coreTypes.js'
export interface MetadataIndexEntry {
field : string
value : string | number | boolean
ids : Set < string >
lastUpdated : number
}
export interface MetadataIndexStats {
totalEntries : number
totalIds : number
fieldsIndexed : string [ ]
lastRebuild : number
indexSize : number // in bytes
}
export interface MetadataIndexConfig {
maxIndexSize? : number // Max number of entries per field value (default: 10000)
rebuildThreshold? : number // Rebuild if index is this % stale (default: 0.1)
autoOptimize? : boolean // Auto-cleanup unused entries (default: true)
indexedFields? : string [ ] // Only index these fields (default: all)
excludeFields? : string [ ] // Never index these fields
}
/ * *
* Manages metadata indexes for fast filtering
* Maintains inverted indexes : field + value - > list of IDs
* /
export class MetadataIndexManager {
private storage : StorageAdapter
private config : Required < MetadataIndexConfig >
private indexCache = new Map < string , MetadataIndexEntry > ( )
private dirtyEntries = new Set < string > ( )
private isRebuilding = false
constructor ( storage : StorageAdapter , config : MetadataIndexConfig = { } ) {
this . storage = storage
this . config = {
maxIndexSize : config.maxIndexSize ? ? 10000 ,
rebuildThreshold : config.rebuildThreshold ? ? 0.1 ,
autoOptimize : config.autoOptimize ? ? true ,
indexedFields : config.indexedFields ? ? [ ] ,
2025-08-06 12:54:46 -07:00
excludeFields : config.excludeFields ? ? [ 'id' , 'createdAt' , 'updatedAt' , 'embedding' , 'vector' , 'embeddings' , 'vectors' ]
2025-08-06 12:29:32 -07:00
}
}
/ * *
* Get index key for field and value
* /
private getIndexKey ( field : string , value : any ) : string {
const normalizedValue = this . normalizeValue ( value )
return ` ${ field } : ${ normalizedValue } `
}
2025-08-06 12:54:46 -07:00
/ * *
* Generate field index filename for filter discovery
* /
private getFieldIndexFilename ( field : string ) : string {
return ` field_ ${ field } `
}
/ * *
* Generate value chunk filename for scalable storage
* /
private getValueChunkFilename ( field : string , value : any , chunkIndex : number = 0 ) : string {
const normalizedValue = this . normalizeValue ( value )
const safeValue = this . makeSafeFilename ( normalizedValue )
return ` ${ field } _ ${ safeValue } _chunk ${ chunkIndex } `
}
/ * *
* Make a value safe for use in filenames
* /
private makeSafeFilename ( value : string ) : string {
// Replace unsafe characters and limit length
return value
. replace ( /[^a-zA-Z0-9-_]/g , '_' )
. substring ( 0 , 50 )
. toLowerCase ( )
}
2025-08-06 12:29:32 -07:00
/ * *
* Normalize value for consistent indexing
* /
private normalizeValue ( value : any ) : string {
if ( value === null || value === undefined ) return '__NULL__'
if ( typeof value === 'boolean' ) return value ? '__TRUE__' : '__FALSE__'
if ( typeof value === 'number' ) return value . toString ( )
2025-08-06 12:54:46 -07:00
if ( Array . isArray ( value ) ) {
const joined = value . map ( v = > this . normalizeValue ( v ) ) . join ( ',' )
// Hash very long array values to avoid filesystem limits
if ( joined . length > 100 ) {
return this . hashValue ( joined )
}
return joined
}
const stringValue = String ( value ) . toLowerCase ( ) . trim ( )
// Hash very long string values to avoid filesystem limits
if ( stringValue . length > 100 ) {
return this . hashValue ( stringValue )
}
return stringValue
}
/ * *
* Create a short hash for long values to avoid filesystem filename limits
* /
private hashValue ( value : string ) : string {
// Simple hash function to create shorter keys
let hash = 0
for ( let i = 0 ; i < value . length ; i ++ ) {
const char = value . charCodeAt ( i )
hash = ( ( hash << 5 ) - hash ) + char
hash = hash & hash // Convert to 32-bit integer
}
return ` __HASH_ ${ Math . abs ( hash ) . toString ( 36 ) } `
2025-08-06 12:29:32 -07:00
}
/ * *
* Check if field should be indexed
* /
private shouldIndexField ( field : string ) : boolean {
if ( this . config . excludeFields . includes ( field ) ) return false
if ( this . config . indexedFields . length > 0 ) {
return this . config . indexedFields . includes ( field )
}
return true
}
/ * *
* Extract indexable field - value pairs from metadata
* /
private extractIndexableFields ( metadata : any ) : Array < { field : string , value : any } > {
const fields : Array < { field : string , value : any } > = [ ]
const extract = ( obj : any , prefix = '' ) : void = > {
for ( const [ key , value ] of Object . entries ( obj ) ) {
const fullKey = prefix ? ` ${ prefix } . ${ key } ` : key
if ( ! this . shouldIndexField ( fullKey ) ) continue
if ( value && typeof value === 'object' && ! Array . isArray ( value ) ) {
// Recurse into nested objects
extract ( value , fullKey )
} else {
// Index this field
fields . push ( { field : fullKey , value } )
// If it's an array, also index each element
if ( Array . isArray ( value ) ) {
for ( const item of value ) {
fields . push ( { field : fullKey , value : item } )
}
}
}
}
}
if ( metadata && typeof metadata === 'object' ) {
extract ( metadata )
}
return fields
}
/ * *
* Add item to metadata indexes
* /
async addToIndex ( id : string , metadata : any ) : Promise < void > {
const fields = this . extractIndexableFields ( metadata )
for ( const { field , value } of fields ) {
const key = this . getIndexKey ( field , value )
// Get or create index entry
let entry = this . indexCache . get ( key )
if ( ! entry ) {
const loadedEntry = await this . loadIndexEntry ( key )
entry = loadedEntry ? ? {
field ,
value : this.normalizeValue ( value ) ,
ids : new Set < string > ( ) ,
lastUpdated : Date.now ( )
}
this . indexCache . set ( key , entry )
}
// Add ID to entry
entry . ids . add ( id )
entry . lastUpdated = Date . now ( )
this . dirtyEntries . add ( key )
}
}
/ * *
* Remove item from metadata indexes
* /
async removeFromIndex ( id : string , metadata? : any ) : Promise < void > {
if ( metadata ) {
// Remove from specific field indexes
const fields = this . extractIndexableFields ( metadata )
for ( const { field , value } of fields ) {
const key = this . getIndexKey ( field , value )
let entry = this . indexCache . get ( key )
if ( ! entry ) {
const loadedEntry = await this . loadIndexEntry ( key )
entry = loadedEntry ? ? undefined
}
if ( entry ) {
entry . ids . delete ( id )
entry . lastUpdated = Date . now ( )
this . dirtyEntries . add ( key )
// If no IDs left, mark for cleanup
if ( entry . ids . size === 0 ) {
this . indexCache . delete ( key )
await this . deleteIndexEntry ( key )
}
}
}
} else {
// Remove from all indexes (slower, requires scanning)
for ( const [ key , entry ] of this . indexCache . entries ( ) ) {
if ( entry . ids . has ( id ) ) {
entry . ids . delete ( id )
entry . lastUpdated = Date . now ( )
this . dirtyEntries . add ( key )
if ( entry . ids . size === 0 ) {
this . indexCache . delete ( key )
await this . deleteIndexEntry ( key )
}
}
}
}
}
/ * *
* Get IDs for a specific field - value combination
* /
async getIds ( field : string , value : any ) : Promise < string [ ] > {
const key = this . getIndexKey ( field , value )
// Try cache first
let entry = this . indexCache . get ( key )
// Load from storage if not cached
if ( ! entry ) {
const loadedEntry = await this . loadIndexEntry ( key )
if ( loadedEntry ) {
entry = loadedEntry
this . indexCache . set ( key , entry )
}
}
return entry ? Array . from ( entry . ids ) : [ ]
}
/ * *
* Convert MongoDB - style filter to simple field - value criteria for indexing
* /
private convertFilterToCriteria ( filter : any ) : Array < { field : string , values : any [ ] } > {
const criteria : Array < { field : string , values : any [ ] } > = [ ]
if ( ! filter || typeof filter !== 'object' ) {
return criteria
}
for ( const [ key , value ] of Object . entries ( filter ) ) {
// Skip logical operators for now - handle them separately
if ( key . startsWith ( '$' ) ) continue
if ( value && typeof value === 'object' && ! Array . isArray ( value ) ) {
// Handle MongoDB operators
for ( const [ op , operand ] of Object . entries ( value ) ) {
switch ( op ) {
case '$in' :
if ( Array . isArray ( operand ) ) {
criteria . push ( { field : key , values : operand } )
}
break
case '$eq' :
criteria . push ( { field : key , values : [ operand ] } )
break
// For other operators, we can't use index efficiently, skip for now
default :
break
}
}
} else {
// Direct value or array
const values = Array . isArray ( value ) ? value : [ value ]
criteria . push ( { field : key , values } )
}
}
return criteria
}
/ * *
* Get IDs matching MongoDB - style metadata filter using indexes where possible
* /
async getIdsForFilter ( filter : any ) : Promise < string [ ] > {
if ( ! filter || Object . keys ( filter ) . length === 0 ) {
return [ ]
}
// Handle logical operators
if ( filter . $and && Array . isArray ( filter . $and ) ) {
// For $and, we need intersection of all sub-filters
const allIds : string [ ] [ ] = [ ]
for ( const subFilter of filter . $and ) {
const subIds = await this . getIdsForFilter ( subFilter )
allIds . push ( subIds )
}
if ( allIds . length === 0 ) return [ ]
if ( allIds . length === 1 ) return allIds [ 0 ]
// Intersection of all sets
return allIds . reduce ( ( intersection , currentSet ) = >
intersection . filter ( id = > currentSet . includes ( id ) )
)
}
if ( filter . $or && Array . isArray ( filter . $or ) ) {
// For $or, we need union of all sub-filters
const unionIds = new Set < string > ( )
for ( const subFilter of filter . $or ) {
const subIds = await this . getIdsForFilter ( subFilter )
subIds . forEach ( id = > unionIds . add ( id ) )
}
return Array . from ( unionIds )
}
// Handle regular field filters
const criteria = this . convertFilterToCriteria ( filter )
const idSets : string [ ] [ ] = [ ]
for ( const { field , values } of criteria ) {
const unionIds = new Set < string > ( )
for ( const value of values ) {
const ids = await this . getIds ( field , value )
ids . forEach ( id = > unionIds . add ( id ) )
}
idSets . push ( Array . from ( unionIds ) )
}
if ( idSets . length === 0 ) return [ ]
if ( idSets . length === 1 ) return idSets [ 0 ]
// Intersection of all field criteria (implicit $and)
return idSets . reduce ( ( intersection , currentSet ) = >
intersection . filter ( id = > currentSet . includes ( id ) )
)
}
/ * *
* Get IDs matching multiple criteria ( intersection ) - LEGACY METHOD
* @deprecated Use getIdsForFilter instead
* /
async getIdsForCriteria ( criteria : Record < string , any > ) : Promise < string [ ] > {
return this . getIdsForFilter ( criteria )
}
/ * *
* Flush dirty entries to storage
* /
async flush ( ) : Promise < void > {
const promises : Promise < void > [ ] = [ ]
for ( const key of this . dirtyEntries ) {
const entry = this . indexCache . get ( key )
if ( entry ) {
promises . push ( this . saveIndexEntry ( key , entry ) )
}
}
await Promise . all ( promises )
this . dirtyEntries . clear ( )
}
/ * *
* Get index statistics
* /
async getStats ( ) : Promise < MetadataIndexStats > {
const fields = new Set < string > ( )
let totalEntries = 0
let totalIds = 0
for ( const entry of this . indexCache . values ( ) ) {
fields . add ( entry . field )
totalEntries ++
totalIds += entry . ids . size
}
return {
totalEntries ,
totalIds ,
fieldsIndexed : Array.from ( fields ) ,
lastRebuild : 0 , // TODO: track rebuild timestamp
indexSize : totalEntries * 100 // rough estimate
}
}
/ * *
* Rebuild entire index from scratch
* /
async rebuild ( ) : Promise < void > {
if ( this . isRebuilding ) return
this . isRebuilding = true
try {
// Clear existing indexes
this . indexCache . clear ( )
this . dirtyEntries . clear ( )
// Get all nouns and rebuild their metadata indexes
const nouns = await this . storage . getAllNouns ( )
for ( const noun of nouns ) {
const metadata = await this . storage . getMetadata ( noun . id )
if ( metadata ) {
await this . addToIndex ( noun . id , metadata )
}
}
// Get all verbs and rebuild their metadata indexes
const verbs = await this . storage . getAllVerbs ( )
for ( const verb of verbs ) {
const metadata = await this . storage . getVerbMetadata ( verb . id )
if ( metadata ) {
await this . addToIndex ( verb . id , metadata )
}
}
// Flush to storage
await this . flush ( )
} finally {
this . isRebuilding = false
}
}
/ * *
2025-08-06 12:54:46 -07:00
* Load index entry from storage using safe filenames
2025-08-06 12:29:32 -07:00
* /
private async loadIndexEntry ( key : string ) : Promise < MetadataIndexEntry | null > {
try {
2025-08-06 12:54:46 -07:00
// Extract field and value from key
const [ field , value ] = key . split ( ':' , 2 )
const filename = this . getValueChunkFilename ( field , value )
// Load from metadata indexes directory with safe filename
const indexId = ` __metadata_index__ ${ filename } `
2025-08-06 12:29:32 -07:00
const data = await this . storage . getMetadata ( indexId )
if ( data ) {
return {
field : data.field ,
value : data.value ,
ids : new Set ( data . ids || [ ] ) ,
lastUpdated : data.lastUpdated || Date . now ( )
}
}
} catch ( error ) {
// Index entry doesn't exist yet
}
return null
}
/ * *
2025-08-06 12:54:46 -07:00
* Save index entry to storage using safe filenames
2025-08-06 12:29:32 -07:00
* /
private async saveIndexEntry ( key : string , entry : MetadataIndexEntry ) : Promise < void > {
const data = {
field : entry.field ,
value : entry.value ,
ids : Array.from ( entry . ids ) ,
lastUpdated : entry.lastUpdated
}
2025-08-06 12:54:46 -07:00
// Extract field and value from key for safe filename generation
const [ field , value ] = key . split ( ':' , 2 )
const filename = this . getValueChunkFilename ( field , value )
// Store metadata indexes with safe filename
const indexId = ` __metadata_index__ ${ filename } `
2025-08-06 12:29:32 -07:00
await this . storage . saveMetadata ( indexId , data )
}
/ * *
2025-08-06 12:54:46 -07:00
* Delete index entry from storage using safe filenames
2025-08-06 12:29:32 -07:00
* /
private async deleteIndexEntry ( key : string ) : Promise < void > {
try {
2025-08-06 12:54:46 -07:00
const [ field , value ] = key . split ( ':' , 2 )
const filename = this . getValueChunkFilename ( field , value )
const indexId = ` __metadata_index__ ${ filename } `
2025-08-06 12:29:32 -07:00
await this . storage . saveMetadata ( indexId , null )
} catch ( error ) {
// Entry might not exist
}
}
}