- Restore blue PageHeader on Dashboard (/app-components) - Update homepage (/) with subtle header design without blue bar - Add uniform PageHeader styling to application edit page - Fix Rapporten link on homepage to point to /reports overview - Improve header descriptions spacing for better readability
60 KiB
Normalized Database Implementation Plan
Executive Summary
Status: Green Field Implementation
Approach: Complete rebuild with normalized structure
Timeline: 1-2 weeks
Risk: Low (no production data to migrate)
This document outlines the complete implementation plan for migrating from JSONB-based storage to a fully normalized, generic database structure that works with any Jira Assets schema.
Table of Contents
- Architecture Overview
- Database Schema
- Implementation Components
- Implementation Steps
- Code Structure
- Testing Strategy
- Migration Path
- Example Implementations
Architecture Overview
Current Architecture (JSONB)
Jira Assets API
↓
jiraAssetsClient.parseObject()
↓
CMDBObject (TypeScript)
↓
cacheStore.upsertObject()
↓
cached_objects.data (JSONB column)
↓
Queries: Load all → Filter in JavaScript ❌
Problems:
- All objects loaded into memory
- No database-level indexing on attributes
- Slow queries with complex filters
- Memory overhead for large datasets
Target Architecture (Normalized)
Jira Assets API
↓
jiraAssetsClient.parseObject()
↓
CMDBObject (TypeScript)
↓
normalizedCacheStore.upsertObject()
↓
Normalize to: objects + attribute_values
↓
Queries: SQL JOINs with indexes ✅
Benefits:
- Database-level filtering
- Indexed attributes
- Efficient queries
- Scalable to large datasets
Key Benefits
- Generic: Works with any Jira Assets schema (discovered dynamically)
- Efficient: Database-level filtering with indexes
- Scalable: Handles large datasets efficiently
- Type-safe: Proper data types per attribute
- Queryable: Complex filters at database level
Database Schema
Tables
1. object_types
Stores discovered object types from Jira schema.
CREATE TABLE object_types (
jira_type_id INTEGER PRIMARY KEY,
type_name TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
description TEXT,
sync_priority INTEGER DEFAULT 0,
object_count INTEGER DEFAULT 0,
discovered_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
Purpose: Metadata about object types discovered from Jira schema.
2. attributes
Stores discovered attributes per object type.
CREATE TABLE attributes (
id SERIAL PRIMARY KEY,
jira_attr_id INTEGER NOT NULL,
object_type_name TEXT NOT NULL REFERENCES object_types(type_name) ON DELETE CASCADE,
attr_name TEXT NOT NULL, -- "Application Function"
field_name TEXT NOT NULL, -- "applicationFunction" (camelCase)
attr_type TEXT NOT NULL, -- 'text', 'reference', 'integer', etc.
is_multiple BOOLEAN NOT NULL DEFAULT FALSE,
is_editable BOOLEAN NOT NULL DEFAULT TRUE,
is_required BOOLEAN NOT NULL DEFAULT FALSE,
is_system BOOLEAN NOT NULL DEFAULT FALSE,
reference_type_name TEXT, -- For reference attributes (e.g., "ApplicationFunction")
description TEXT,
discovered_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(jira_attr_id, object_type_name)
);
Purpose: Metadata about attributes discovered from Jira schema. Used to build queries dynamically.
3. objects
Stores minimal object metadata.
CREATE TABLE objects (
id TEXT PRIMARY KEY,
object_key TEXT NOT NULL UNIQUE,
object_type_name TEXT NOT NULL REFERENCES object_types(type_name) ON DELETE CASCADE,
label TEXT NOT NULL,
jira_updated_at TIMESTAMP,
jira_created_at TIMESTAMP,
cached_at TIMESTAMP NOT NULL DEFAULT NOW()
);
Purpose: Core object information. All attribute values stored separately in attribute_values.
4. attribute_values
EAV pattern for storing all attribute values.
CREATE TABLE attribute_values (
id SERIAL PRIMARY KEY,
object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
attribute_id INTEGER NOT NULL REFERENCES attributes(id) ON DELETE CASCADE,
-- Value storage (one column populated based on type):
text_value TEXT, -- For text, textarea, url, email, select, user, status
number_value NUMERIC, -- For integer, float
boolean_value BOOLEAN, -- For boolean
date_value DATE, -- For date
datetime_value TIMESTAMP, -- For datetime
reference_object_id TEXT, -- For reference attributes (stores objectId)
-- For arrays: multiple rows with different array_index
array_index INTEGER DEFAULT 0, -- 0 = single value, >0 = array element
UNIQUE(object_id, attribute_id, array_index)
);
Purpose: Stores all attribute values in normalized form. One row per value (multiple rows for arrays).
5. object_relations
Enhanced existing table with attribute_id reference.
CREATE TABLE object_relations (
id SERIAL PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
target_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
attribute_id INTEGER NOT NULL REFERENCES attributes(id) ON DELETE CASCADE,
source_type TEXT NOT NULL,
target_type TEXT NOT NULL,
UNIQUE(source_id, target_id, attribute_id)
);
Purpose: Stores relationships between objects. Enhanced with attribute_id for better queries.
6. sync_metadata
Unchanged.
CREATE TABLE sync_metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
Indexes
Critical for query performance:
-- Objects
CREATE INDEX idx_objects_type ON objects(object_type_name);
CREATE INDEX idx_objects_key ON objects(object_key);
CREATE INDEX idx_objects_label ON objects(label);
-- Attributes (for schema lookups)
CREATE INDEX idx_attributes_type ON attributes(object_type_name);
CREATE INDEX idx_attributes_field ON attributes(field_name);
CREATE INDEX idx_attributes_type_field ON attributes(object_type_name, field_name);
-- Attribute Values (critical for query performance)
CREATE INDEX idx_attr_values_object ON attribute_values(object_id);
CREATE INDEX idx_attr_values_attr ON attribute_values(attribute_id);
CREATE INDEX idx_attr_values_text ON attribute_values(text_value) WHERE text_value IS NOT NULL;
CREATE INDEX idx_attr_values_number ON attribute_values(number_value) WHERE number_value IS NOT NULL;
CREATE INDEX idx_attr_values_reference ON attribute_values(reference_object_id) WHERE reference_object_id IS NOT NULL;
CREATE INDEX idx_attr_values_composite_text ON attribute_values(attribute_id, text_value) WHERE text_value IS NOT NULL;
CREATE INDEX idx_attr_values_composite_ref ON attribute_values(attribute_id, reference_object_id) WHERE reference_object_id IS NOT NULL;
CREATE INDEX idx_attr_values_object_attr ON attribute_values(object_id, attribute_id);
-- Relations
CREATE INDEX idx_relations_source ON object_relations(source_id);
CREATE INDEX idx_relations_target ON object_relations(target_id);
CREATE INDEX idx_relations_attr ON object_relations(attribute_id);
Implementation Components
1. Schema Discovery Service
File: backend/src/services/schemaDiscoveryService.ts
Purpose: Populate object_types and attributes tables from generated schema.
Methods:
class SchemaDiscoveryService {
/**
* Discover schema from OBJECT_TYPES and populate database
*/
async discoverAndStoreSchema(): Promise<void>;
/**
* Get object type definition from database
*/
async getObjectType(typeName: string): Promise<ObjectTypeDefinition | null>;
/**
* Get attribute definition by type and field name
*/
async getAttribute(typeName: string, fieldName: string): Promise<AttributeDefinition | null>;
/**
* Get all attributes for an object type
*/
async getAttributesForType(typeName: string): Promise<AttributeDefinition[]>;
}
Implementation Notes:
- Reads from
OBJECT_TYPESingenerated/jira-schema.ts - Populates
object_typesandattributestables - Called once at startup or when schema changes
- Idempotent (can be called multiple times)
2. Normalized Cache Store
File: backend/src/services/normalizedCacheStore.ts
Purpose: Replace cacheStore.ts with normalized implementation.
Key Methods:
Object Operations
class NormalizedCacheStore {
/**
* Upsert object: normalize and store
*/
async upsertObject<T extends CMDBObject>(
typeName: CMDBObjectTypeName,
object: T
): Promise<void>;
/**
* Get object: reconstruct from normalized data
*/
async getObject<T extends CMDBObject>(
typeName: CMDBObjectTypeName,
id: string
): Promise<T | null>;
/**
* Get objects with optional filters
*/
async getObjects<T extends CMDBObject>(
typeName: CMDBObjectTypeName,
options?: QueryOptions
): Promise<T[]>;
/**
* Delete object and all its attribute values
*/
async deleteObject(
typeName: CMDBObjectTypeName,
id: string
): Promise<boolean>;
}
Normalization Logic
/**
* Convert CMDBObject to normalized form
*/
private async normalizeObject(
object: CMDBObject,
typeName: CMDBObjectTypeName
): Promise<void>;
/**
* Store attribute value based on type
*/
private async storeAttributeValue(
objectId: string,
attributeId: number,
value: unknown,
attrDef: AttributeDefinition,
arrayIndex?: number
): Promise<void>;
Reconstruction Logic
/**
* Reconstruct CMDBObject from normalized data
*/
private async reconstructObject<T extends CMDBObject>(
objectId: string,
typeName: CMDBObjectTypeName
): Promise<T | null>;
/**
* Load all attribute values for an object
*/
private async loadAttributeValues(
objectId: string,
typeName: CMDBObjectTypeName
): Promise<Map<string, unknown>>;
Query Operations
/**
* Query objects with filters (generic)
*/
async queryWithFilters<T extends CMDBObject>(
typeName: CMDBObjectTypeName,
filters: Record<string, unknown>,
options?: QueryOptions
): Promise<{ objects: T[]; total: number }>;
3. Generic Query Builder
File: backend/src/services/queryBuilder.ts
Purpose: Build SQL queries dynamically based on filters.
Methods:
class QueryBuilder {
/**
* Build WHERE clause from filters
*/
buildWhereClause(
filters: Record<string, unknown>,
typeName: CMDBObjectTypeName
): { whereClause: string; params: unknown[] };
/**
* Build filter condition for one field
*/
buildFilterCondition(
fieldName: string,
filterValue: unknown,
attrDef: AttributeDefinition
): { condition: string; params: unknown[] };
/**
* Build ORDER BY clause
*/
buildOrderBy(orderBy?: string, orderDir?: 'ASC' | 'DESC'): string;
/**
* Build pagination clause
*/
buildPagination(limit?: number, offset?: number): string;
}
Filter Types Supported:
-
Exact match:
{ status: "Active" } -
Contains (text):
{ name: { contains: "search" } } -
Reference match:
{ governanceModel: { objectId: "123" } } { governanceModel: { objectKey: "GOV-A" } } { governanceModel: { label: "Model A" } } -
Array contains:
{ applicationFunction: [ { objectId: "1" }, { objectId: "2" } ]} -
Exists:
{ applicationFunction: { exists: true } } -
Empty:
{ applicationFunction: { empty: true } }
4. Updated Services
syncEngine.ts
- Update
syncObjectType()to usenormalizedCacheStore - Keep same interface, different implementation
- No changes to sync logic, only storage layer
dataService.ts
- Update
searchApplications()to usequeryWithFilters() - Remove JavaScript filtering logic
- Use SQL queries instead
- Much faster for complex filters
cmdbService.ts
- Update to use
normalizedCacheStoreinstead ofcacheStore - Keep same interface
- No changes to business logic
Implementation Steps
Step 1: Create Database Schema
Files:
backend/src/services/database/normalized-schema.ts- Schema definitions- Update
backend/src/services/database/migrations.ts- Add migration
Tasks:
- Create schema SQL for PostgreSQL
- Create schema SQL for SQLite (development)
- Create migration function
- Test schema creation
Deliverables:
- Schema SQL files
- Migration function
- Tests pass
Step 2: Schema Discovery Service
File: backend/src/services/schemaDiscoveryService.ts
Tasks:
- Implement
discoverAndStoreSchema()- Read fromOBJECT_TYPES - Populate
object_typestable - Populate
attributestable - Add validation and error handling
- Add caching (don't rediscover if already done)
Deliverables:
- Schema discovery service
- Tests pass
- Schema populated in database
Step 3: Normalization Logic
File: backend/src/services/normalizedCacheStore.ts (partial)
Tasks:
- Implement
normalizeObject()- Convert CMDBObject to normalized form - Handle all attribute types:
- Text (text, textarea, url, email, select, user, status)
- Numbers (integer, float)
- Boolean
- Dates (date, datetime)
- References (single and multiple)
- Implement
storeAttributeValue()- Store values in correct columns - Handle arrays (multiple rows with array_index)
- Test normalization with sample objects
Deliverables:
- Normalization logic
- Tests for all attribute types
- Sample data normalized correctly
Step 4: Reconstruction Logic
File: backend/src/services/normalizedCacheStore.ts (partial)
Tasks:
- Implement
reconstructObject()- Build CMDBObject from normalized data - Load all attribute values for object
- Convert back to TypeScript types
- Handle arrays (multiple rows)
- Handle references (load referenced objects if needed)
- Test reconstruction correctness
Deliverables:
- Reconstruction logic
- Tests for all attribute types
- Reconstructed objects match original
Step 5: Basic CRUD Operations
File: backend/src/services/normalizedCacheStore.ts (partial)
Tasks:
- Implement
upsertObject()- Normalize and store - Implement
getObject()- Reconstruct and return - Implement
getObjects()- Basic query (no filters yet) - Implement
deleteObject()- Delete object and values - Implement
countObjects()- Count by type - Test CRUD operations
Deliverables:
- CRUD operations working
- Tests pass
- Data integrity verified
Step 6: Generic Query Builder
File: backend/src/services/queryBuilder.ts
Tasks:
- Implement filter condition builder
- Support all filter types:
- Exact match
- Contains
- Reference match
- Array contains
- Exists
- Empty
- Build WHERE clauses dynamically
- Handle JOINs for attribute values
- Test query generation
Deliverables:
- Query builder
- Tests for all filter types
- SQL generation correct
Step 7: Query Operations
File: backend/src/services/normalizedCacheStore.ts (partial)
Tasks:
- Implement
queryWithFilters()- Use query builder - Implement
countWithFilters()- Count with same filters - Add pagination support
- Add sorting support
- Test complex queries
Deliverables:
- Query operations working
- Performance tests pass
- Complex filters work correctly
Step 8: Relations
File: backend/src/services/normalizedCacheStore.ts (partial)
Tasks:
- Update
extractAndStoreRelations()- Use attribute_id - Update
getRelatedObjects()- Use normalized queries - Update
getReferencingObjects()- Use normalized queries - Test relations
Deliverables:
- Relations working
- Tests pass
- Performance acceptable
Step 9: Update Services
Files:
backend/src/services/syncEngine.tsbackend/src/services/cmdbService.tsbackend/src/services/dataService.ts
Tasks:
- Replace
cacheStoreimports withnormalizedCacheStore - Update
dataService.searchApplications()- UsequeryWithFilters() - Remove JavaScript filtering logic
- Update all service calls
- Test all endpoints
Deliverables:
- All services updated
- All endpoints working
- Tests pass
Step 10: Statistics & Utilities
File: backend/src/services/normalizedCacheStore.ts (partial)
Tasks:
- Implement
getStats()- Count from normalized tables - Implement
isWarm()- Check if cache has data - Implement
clearObjectType()- Clear type and values - Implement
clearAll()- Clear all data - Test statistics
Deliverables:
- Statistics working
- Tests pass
- Performance acceptable
Step 11: Remove Old Code
Files:
backend/src/services/cacheStore.ts- Delete or archive
Tasks:
- Remove old
cacheStore.ts - Update all imports
- Clean up unused code
- Update documentation
Deliverables:
- Old code removed
- All imports updated
- Documentation updated
Code Structure
File Organization
backend/src/services/
├── database/
│ ├── normalized-schema.ts # NEW: Schema definitions
│ ├── migrations.ts # UPDATED: Add normalized schema migration
│ ├── factory.ts # (unchanged)
│ ├── interface.ts # (unchanged)
│ ├── postgresAdapter.ts # (unchanged)
│ └── sqliteAdapter.ts # (unchanged)
├── schemaDiscoveryService.ts # NEW: Schema discovery
├── normalizedCacheStore.ts # NEW: Normalized storage
├── queryBuilder.ts # NEW: Generic query builder
├── syncEngine.ts # UPDATED: Use normalized store
├── cmdbService.ts # UPDATED: Use normalized store
├── dataService.ts # UPDATED: Use queries
└── cacheStore.ts # REMOVE: Old implementation
Key Interfaces
// NormalizedCacheStore interface (same as old CacheStore)
interface NormalizedCacheStore {
// Object operations
upsertObject<T>(typeName: CMDBObjectTypeName, object: T): Promise<void>;
getObject<T>(typeName: CMDBObjectTypeName, id: string): Promise<T | null>;
getObjectByKey<T>(typeName: CMDBObjectTypeName, objectKey: string): Promise<T | null>;
getObjects<T>(typeName: CMDBObjectTypeName, options?: QueryOptions): Promise<T[]>;
countObjects(typeName: CMDBObjectTypeName): Promise<number>;
deleteObject(typeName: CMDBObjectTypeName, id: string): Promise<boolean>;
// Query operations
queryWithFilters<T>(
typeName: CMDBObjectTypeName,
filters: Record<string, unknown>,
options?: QueryOptions
): Promise<{ objects: T[]; total: number }>;
// Relations
extractAndStoreRelations<T>(typeName: CMDBObjectTypeName, object: T): Promise<void>;
getRelatedObjects<T>(
sourceId: string,
targetTypeName: CMDBObjectTypeName,
attributeName?: string
): Promise<T[]>;
getReferencingObjects<T>(
targetId: string,
sourceTypeName: CMDBObjectTypeName,
attributeName?: string
): Promise<T[]>;
// Statistics
getStats(): Promise<CacheStats>;
isWarm(): Promise<boolean>;
clearObjectType(typeName: CMDBObjectTypeName): Promise<number>;
clearAll(): Promise<void>;
// Sync metadata
getSyncMetadata(key: string): Promise<string | null>;
setSyncMetadata(key: string, value: string): Promise<void>;
}
// QueryBuilder interface
interface QueryBuilder {
buildWhereClause(
filters: Record<string, unknown>,
typeName: CMDBObjectTypeName
): { whereClause: string; params: unknown[] };
buildFilterCondition(
fieldName: string,
filterValue: unknown,
attrDef: AttributeDefinition
): { condition: string; params: unknown[] };
}
Testing Strategy
Unit Tests
-
Schema Discovery
- Test schema population
- Test attribute lookup
- Test object type lookup
- Test idempotency
-
Normalization
- Test all attribute types (text, number, boolean, date, reference)
- Test arrays (multiple values)
- Test null/empty values
- Test edge cases
-
Reconstruction
- Test object reconstruction
- Test all attribute types
- Test arrays
- Test references
- Test missing values
-
Query Builder
- Test all filter types
- Test complex filters
- Test SQL generation
- Test parameter binding
Integration Tests
-
CRUD Operations
- Create, read, update, delete
- Verify data integrity
- Test transactions
-
Queries
- Simple filters
- Complex filters (multiple conditions)
- Pagination
- Sorting
- Performance with large datasets
-
Relations
- Store relations
- Query relations
- Delete relations
- Cascade deletes
Performance Tests
-
Query Performance
- Compare old vs new
- Test with 500+ objects
- Test complex filters
- Measure query time
-
Write Performance
- Batch inserts
- Single inserts
- Updates
- Measure write time
-
Memory Usage
- Compare old vs new
- Test with large datasets
- Measure memory footprint
Migration Path
Since it's Green Field
-
No Data Migration Needed
- Start fresh with normalized structure
- No existing data to migrate
- Clean implementation
-
Implementation Order
- Build new normalized structure
- Test thoroughly
- Replace old code
- Deploy
-
Rollback Plan
- Keep old code in git history
- Can revert if needed
- No data loss risk (green field)
Example Implementations
Example 1: Normalization
Input (CMDBObject):
{
id: "123",
objectKey: "APP-1",
label: "My Application",
status: "Active",
applicationFunction: [
{ objectId: "456", objectKey: "FUNC-1", label: "Function 1" },
{ objectId: "789", objectKey: "FUNC-2", label: "Function 2" }
],
ictGovernanceModel: { objectId: "999", objectKey: "GOV-A", label: "Model A" },
customDevelopment: true,
zenyaID: 42
}
Normalized (objects + attribute_values):
-- objects table
INSERT INTO objects (id, object_key, object_type_name, label, jira_updated_at, jira_created_at, cached_at)
VALUES ('123', 'APP-1', 'ApplicationComponent', 'My Application', '2024-01-01', '2024-01-01', NOW());
-- attribute_values table
INSERT INTO attribute_values (object_id, attribute_id, text_value, number_value, boolean_value, reference_object_id, array_index)
VALUES
-- status (text)
('123', <status_attr_id>, 'Active', NULL, NULL, NULL, 0),
-- applicationFunction (reference array)
('123', <appfunc_attr_id>, NULL, NULL, NULL, '456', 0), -- First function
('123', <appfunc_attr_id>, NULL, NULL, NULL, '789', 1), -- Second function
-- ictGovernanceModel (reference)
('123', <gov_attr_id>, NULL, NULL, NULL, '999', 0),
-- customDevelopment (boolean)
('123', <customdev_attr_id>, NULL, NULL, true, NULL, 0),
-- zenyaID (integer)
('123', <zenyaid_attr_id>, NULL, 42, NULL, NULL, 0);
Example 2: Query
Filter:
{
status: "Active",
governanceModel: { objectId: "999" },
applicationFunction: { exists: true }
}
Generated SQL:
SELECT DISTINCT o.*
FROM objects o
WHERE o.object_type_name = 'ApplicationComponent'
AND EXISTS (
SELECT 1 FROM attribute_values av
JOIN attributes a ON av.attribute_id = a.id
WHERE av.object_id = o.id
AND a.field_name = 'status'
AND av.text_value = $1
)
AND EXISTS (
SELECT 1 FROM attribute_values av
JOIN attributes a ON av.attribute_id = a.id
WHERE av.object_id = o.id
AND a.field_name = 'ictGovernanceModel'
AND av.reference_object_id = $2
)
AND EXISTS (
SELECT 1 FROM attribute_values av
JOIN attributes a ON av.attribute_id = a.id
WHERE av.object_id = o.id
AND a.field_name = 'applicationFunction'
)
ORDER BY o.label ASC
LIMIT $3 OFFSET $4;
Parameters: ['Active', '999', 25, 0]
Example 3: Reconstruction
Query:
SELECT o.*, a.field_name, av.*
FROM objects o
LEFT JOIN attribute_values av ON av.object_id = o.id
LEFT JOIN attributes a ON av.attribute_id = a.id
WHERE o.id = '123'
ORDER BY a.field_name, av.array_index;
Result Processing:
// Group by field_name
const values: Record<string, unknown> = {};
for (const row of rows) {
const fieldName = row.field_name;
const value = getValueFromRow(row); // Extract from correct column
if (row.array_index === 0) {
// Single value
values[fieldName] = value;
} else {
// Array value
if (!values[fieldName]) values[fieldName] = [];
(values[fieldName] as unknown[]).push(value);
}
}
// Build CMDBObject
const object: CMDBObject = {
id: row.id,
objectKey: row.object_key,
label: row.label,
...values
};
Success Criteria
Functional Requirements
- ✅ All existing functionality works
- ✅ Queries return correct results
- ✅ No data loss
- ✅ Relations work correctly
- ✅ Sync process works
Performance Requirements
- ✅ Query performance: 50%+ faster for filtered queries
- ✅ Memory usage: 30%+ reduction
- ✅ Write performance: No degradation (< 10% slower acceptable)
- ✅ Database size: Similar or smaller
Quality Requirements
- ✅ Test coverage: 80%+ for new code
- ✅ No critical bugs
- ✅ Code well documented
- ✅ Type-safe implementation
Timeline
| Step | Duration | Description |
|---|---|---|
| 1 | 0.5 day | Database schema |
| 2 | 0.5 day | Schema discovery |
| 3 | 1 day | Normalization logic |
| 4 | 1 day | Reconstruction logic |
| 5 | 0.5 day | Basic CRUD |
| 6 | 1 day | Query builder |
| 7 | 1 day | Query operations |
| 8 | 0.5 day | Relations |
| 9 | 1 day | Update services |
| 10 | 0.5 day | Statistics |
| 11 | 0.5 day | Cleanup |
| Total | 8 days |
Buffer: +2 days for unexpected issues
Total Estimated: 1.5-2 weeks
Next Steps
- ✅ Review and approve plan
- ✅ Create feature branch:
feature/normalized-database - ✅ Start implementation (Step 1)
- ✅ Daily progress updates
- ✅ Weekly review
Appendix
A. Attribute Type Mapping
| Jira Type | Database Column | TypeScript Type |
|---|---|---|
| text, textarea, url, email, select, user, status | text_value | string |
| integer, float | number_value | number |
| boolean | boolean_value | boolean |
| date | date_value | string (ISO date) |
| datetime | datetime_value | string (ISO datetime) |
| reference | reference_object_id | ObjectReference |
B. Query Performance Comparison
Old (JSONB + JavaScript filtering):
- Load all objects: ~500ms
- Filter in JavaScript: ~50ms
- Total: ~550ms
New (Normalized + SQL):
- Query with indexes: ~20ms
- Total: ~20ms
Improvement: ~27x faster
C. Database Size Comparison
Old (JSONB):
- 500 objects × ~5KB JSON = ~2.5MB
New (Normalized):
- 500 objects × ~100 bytes = ~50KB (objects)
- 500 objects × ~20 attributes × ~50 bytes = ~500KB (attribute_values)
- Total: ~550KB
Improvement: ~4.5x smaller
Detailed Implementation Guide
Safety Analysis
✅ SAFE TO IMPLEMENT - Here's why:
-
Green Field Status
- No production data exists
- No migration needed
- Clean implementation possible
-
Interface Compatibility
- New
normalizedCacheStorewill implement exact same interface ascacheStore - Same method signatures
- Same return types
- Drop-in replacement
- New
-
Limited Dependencies
- Only 4 files import
cacheStore:syncEngine.tscmdbService.tsdataService.tsroutes/cache.ts
- Easy to replace all at once
- Only 4 files import
-
Separate Database
- Classifications database (
database.ts) is separate - Won't be affected by changes
- No risk to existing data
- Classifications database (
-
Rollback Plan
- Old code in git history
- Can revert if needed
- No data loss risk
Complete Interface Definition
The new normalizedCacheStore must implement this exact interface:
// backend/src/services/normalizedCacheStore.ts
export interface CacheStats {
totalObjects: number;
objectsByType: Record<string, number>;
totalRelations: number;
lastFullSync: string | null;
lastIncrementalSync: string | null;
isWarm: boolean;
dbSizeBytes: number;
}
export interface QueryOptions {
limit?: number;
offset?: number;
orderBy?: string;
orderDir?: 'ASC' | 'DESC';
}
class NormalizedCacheStore {
// Object Operations
async getObject<T extends CMDBObject>(typeName: CMDBObjectTypeName, id: string): Promise<T | null>;
async getObjectByKey<T extends CMDBObject>(typeName: CMDBObjectTypeName, objectKey: string): Promise<T | null>;
async getObjects<T extends CMDBObject>(typeName: CMDBObjectTypeName, options?: QueryOptions): Promise<T[]>;
async countObjects(typeName: CMDBObjectTypeName): Promise<number>;
async searchByLabel<T extends CMDBObject>(typeName: CMDBObjectTypeName, searchTerm: string, options?: QueryOptions): Promise<T[]>;
async searchAllTypes(searchTerm: string, options?: QueryOptions): Promise<CMDBObject[]>;
async upsertObject<T extends CMDBObject>(typeName: CMDBObjectTypeName, object: T): Promise<void>;
async batchUpsertObjects<T extends CMDBObject>(typeName: CMDBObjectTypeName, objects: T[]): Promise<void>;
async deleteObject(typeName: CMDBObjectTypeName, id: string): Promise<boolean>;
async clearObjectType(typeName: CMDBObjectTypeName): Promise<number>;
async clearAll(): Promise<void>;
// Relations
async upsertRelation(sourceId: string, targetId: string, attributeName: string, sourceType: string, targetType: string): Promise<void>;
async batchUpsertRelations(relations: Array<{sourceId: string; targetId: string; attributeName: string; sourceType: string; targetType: string}>): Promise<void>;
async getRelatedObjects<T extends CMDBObject>(sourceId: string, targetTypeName: CMDBObjectTypeName, attributeName?: string): Promise<T[]>;
async getReferencingObjects<T extends CMDBObject>(targetId: string, sourceTypeName: CMDBObjectTypeName, attributeName?: string): Promise<T[]>;
async deleteRelationsForObject(objectId: string): Promise<void>;
async extractAndStoreRelations<T extends CMDBObject>(typeName: CMDBObjectTypeName, object: T): Promise<void>;
// Sync Metadata
async getSyncMetadata(key: string): Promise<string | null>;
async setSyncMetadata(key: string, value: string): Promise<void>;
async deleteSyncMetadata(key: string): Promise<void>;
// Statistics
async getStats(): Promise<CacheStats>;
async isWarm(): Promise<boolean>;
// Utility
async close(): Promise<void>;
}
export const normalizedCacheStore = new NormalizedCacheStore();
Detailed Code Implementations
1. Database Schema (Step 1)
File: backend/src/services/database/normalized-schema.ts
/**
* Normalized Database Schema
*
* Generic, schema-agnostic normalized structure for CMDB data.
*/
export const NORMALIZED_SCHEMA_POSTGRES = `
-- Object Types
CREATE TABLE IF NOT EXISTS object_types (
jira_type_id INTEGER PRIMARY KEY,
type_name TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
description TEXT,
sync_priority INTEGER DEFAULT 0,
object_count INTEGER DEFAULT 0,
discovered_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Attributes
CREATE TABLE IF NOT EXISTS attributes (
id SERIAL PRIMARY KEY,
jira_attr_id INTEGER NOT NULL,
object_type_name TEXT NOT NULL REFERENCES object_types(type_name) ON DELETE CASCADE,
attr_name TEXT NOT NULL,
field_name TEXT NOT NULL,
attr_type TEXT NOT NULL,
is_multiple BOOLEAN NOT NULL DEFAULT FALSE,
is_editable BOOLEAN NOT NULL DEFAULT TRUE,
is_required BOOLEAN NOT NULL DEFAULT FALSE,
is_system BOOLEAN NOT NULL DEFAULT FALSE,
reference_type_name TEXT,
description TEXT,
discovered_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(jira_attr_id, object_type_name)
);
-- Objects
CREATE TABLE IF NOT EXISTS objects (
id TEXT PRIMARY KEY,
object_key TEXT NOT NULL UNIQUE,
object_type_name TEXT NOT NULL REFERENCES object_types(type_name) ON DELETE CASCADE,
label TEXT NOT NULL,
jira_updated_at TIMESTAMP,
jira_created_at TIMESTAMP,
cached_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Attribute Values
CREATE TABLE IF NOT EXISTS attribute_values (
id SERIAL PRIMARY KEY,
object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
attribute_id INTEGER NOT NULL REFERENCES attributes(id) ON DELETE CASCADE,
text_value TEXT,
number_value NUMERIC,
boolean_value BOOLEAN,
date_value DATE,
datetime_value TIMESTAMP,
reference_object_id TEXT,
array_index INTEGER DEFAULT 0,
UNIQUE(object_id, attribute_id, array_index)
);
-- Relations (enhanced)
CREATE TABLE IF NOT EXISTS object_relations (
id SERIAL PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
target_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
attribute_id INTEGER NOT NULL REFERENCES attributes(id) ON DELETE CASCADE,
source_type TEXT NOT NULL,
target_type TEXT NOT NULL,
UNIQUE(source_id, target_id, attribute_id)
);
-- Sync Metadata
CREATE TABLE IF NOT EXISTS sync_metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_objects_type ON objects(object_type_name);
CREATE INDEX IF NOT EXISTS idx_objects_key ON objects(object_key);
CREATE INDEX IF NOT EXISTS idx_objects_label ON objects(label);
CREATE INDEX IF NOT EXISTS idx_attributes_type ON attributes(object_type_name);
CREATE INDEX IF NOT EXISTS idx_attributes_field ON attributes(field_name);
CREATE INDEX IF NOT EXISTS idx_attributes_type_field ON attributes(object_type_name, field_name);
CREATE INDEX IF NOT EXISTS idx_attr_values_object ON attribute_values(object_id);
CREATE INDEX IF NOT EXISTS idx_attr_values_attr ON attribute_values(attribute_id);
CREATE INDEX IF NOT EXISTS idx_attr_values_text ON attribute_values(text_value) WHERE text_value IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_attr_values_number ON attribute_values(number_value) WHERE number_value IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_attr_values_reference ON attribute_values(reference_object_id) WHERE reference_object_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_attr_values_composite_text ON attribute_values(attribute_id, text_value) WHERE text_value IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_attr_values_composite_ref ON attribute_values(attribute_id, reference_object_id) WHERE reference_object_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_relations_source ON object_relations(source_id);
CREATE INDEX IF NOT EXISTS idx_relations_target ON object_relations(target_id);
CREATE INDEX IF NOT EXISTS idx_relations_attr ON object_relations(attribute_id);
`;
export const NORMALIZED_SCHEMA_SQLITE = `
-- SQLite version (same structure, different syntax)
CREATE TABLE IF NOT EXISTS object_types (
jira_type_id INTEGER PRIMARY KEY,
type_name TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
description TEXT,
sync_priority INTEGER DEFAULT 0,
object_count INTEGER DEFAULT 0,
discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ... (similar for all tables, using INTEGER for booleans, TEXT for timestamps)
`;
2. Schema Discovery Service (Step 2)
File: backend/src/services/schemaDiscoveryService.ts
import { logger } from './logger.js';
import { createDatabaseAdapter } from './database/factory.js';
import type { DatabaseAdapter } from './database/interface.js';
import { OBJECT_TYPES } from '../generated/jira-schema.js';
import type { ObjectTypeDefinition, AttributeDefinition } from '../generated/jira-schema.js';
class SchemaDiscoveryService {
private db: DatabaseAdapter;
private isPostgres: boolean;
private discovered: boolean = false;
constructor() {
this.db = createDatabaseAdapter();
this.isPostgres = (process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql');
}
/**
* Discover schema from OBJECT_TYPES and populate database
*/
async discoverAndStoreSchema(): Promise<void> {
if (this.discovered) {
logger.debug('SchemaDiscovery: Schema already discovered, skipping');
return;
}
logger.info('SchemaDiscovery: Discovering schema from OBJECT_TYPES...');
await this.db.transaction(async (db) => {
// Store object types
for (const [typeName, typeDef] of Object.entries(OBJECT_TYPES)) {
await db.execute(`
INSERT INTO object_types (jira_type_id, type_name, display_name, description, sync_priority, object_count, discovered_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(jira_type_id) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
sync_priority = excluded.sync_priority,
object_count = excluded.object_count,
updated_at = excluded.updated_at
`, [
typeDef.jiraTypeId,
typeName,
typeDef.name,
null, // description
typeDef.syncPriority,
typeDef.objectCount,
new Date().toISOString(),
new Date().toISOString()
]);
// Store attributes
for (const attrDef of typeDef.attributes) {
await db.execute(`
INSERT INTO attributes (
jira_attr_id, object_type_name, attr_name, field_name, attr_type,
is_multiple, is_editable, is_required, is_system,
reference_type_name, description, discovered_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(jira_attr_id, object_type_name) DO UPDATE SET
attr_name = excluded.attr_name,
field_name = excluded.field_name,
attr_type = excluded.attr_type,
is_multiple = excluded.is_multiple,
is_editable = excluded.is_editable,
is_required = excluded.is_required,
is_system = excluded.is_system,
reference_type_name = excluded.reference_type_name,
description = excluded.description
`, [
attrDef.jiraId,
typeName,
attrDef.name,
attrDef.fieldName,
attrDef.type,
attrDef.isMultiple,
attrDef.isEditable,
attrDef.isRequired,
attrDef.isSystem,
attrDef.referenceTypeName || null,
attrDef.description || null,
new Date().toISOString()
]);
}
}
});
this.discovered = true;
logger.info('SchemaDiscovery: Schema discovery complete');
}
/**
* Get attribute definition from database
*/
async getAttribute(typeName: string, fieldName: string): Promise<AttributeDefinition | null> {
const row = await this.db.queryOne<{
jira_attr_id: number;
attr_name: string;
field_name: string;
attr_type: string;
is_multiple: boolean;
is_editable: boolean;
is_required: boolean;
is_system: boolean;
reference_type_name: string | null;
description: string | null;
}>(`
SELECT * FROM attributes
WHERE object_type_name = ? AND field_name = ?
`, [typeName, fieldName]);
if (!row) return null;
return {
jiraId: row.jira_attr_id,
name: row.attr_name,
fieldName: row.field_name,
type: row.attr_type as AttributeDefinition['type'],
isMultiple: row.is_multiple,
isEditable: row.is_editable,
isRequired: row.is_required,
isSystem: row.is_system,
referenceTypeName: row.reference_type_name || undefined,
description: row.description || undefined,
};
}
/**
* Get all attributes for a type
*/
async getAttributesForType(typeName: string): Promise<AttributeDefinition[]> {
const rows = await this.db.query<{
jira_attr_id: number;
attr_name: string;
field_name: string;
attr_type: string;
is_multiple: boolean;
is_editable: boolean;
is_required: boolean;
is_system: boolean;
reference_type_name: string | null;
description: string | null;
}>(`
SELECT * FROM attributes
WHERE object_type_name = ?
ORDER BY jira_attr_id
`, [typeName]);
return rows.map(row => ({
jiraId: row.jira_attr_id,
name: row.attr_name,
fieldName: row.field_name,
type: row.attr_type as AttributeDefinition['type'],
isMultiple: row.is_multiple,
isEditable: row.is_editable,
isRequired: row.is_required,
isSystem: row.is_system,
referenceTypeName: row.reference_type_name || undefined,
description: row.description || undefined,
}));
}
}
export const schemaDiscoveryService = new SchemaDiscoveryService();
3. Normalization Logic (Step 3)
File: backend/src/services/normalizedCacheStore.ts (partial - normalization methods)
/**
* Normalize a CMDBObject to database format
*/
private async normalizeObject(
object: CMDBObject,
typeName: CMDBObjectTypeName
): Promise<void> {
// 1. Insert/update object record
await this.db.execute(`
INSERT INTO objects (id, object_key, object_type_name, label, jira_updated_at, jira_created_at, cached_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
object_key = excluded.object_key,
label = excluded.label,
jira_updated_at = excluded.jira_updated_at,
cached_at = excluded.cached_at
`, [
object.id,
object.objectKey,
typeName,
object.label,
object._jiraUpdatedAt || null,
object._jiraCreatedAt || null,
new Date().toISOString()
]);
// 2. Get all attributes for this type
const attributes = await schemaDiscoveryService.getAttributesForType(typeName);
// 3. Delete existing attribute values
await this.db.execute(`
DELETE FROM attribute_values WHERE object_id = ?
`, [object.id]);
// 4. Store each attribute value
for (const attrDef of attributes) {
const value = (object as unknown as Record<string, unknown>)[attrDef.fieldName];
await this.storeAttributeValue(object.id, attrDef, value);
}
}
/**
* Store attribute value based on type
*/
private async storeAttributeValue(
objectId: string,
attrDef: AttributeDefinition,
value: unknown
): Promise<void> {
// Get attribute ID from database
const attrRow = await this.db.queryOne<{ id: number }>(`
SELECT id FROM attributes
WHERE object_type_name = ? AND field_name = ?
`, [attrDef.objectTypeName || '', attrDef.fieldName]);
if (!attrRow) {
logger.warn(`NormalizedCacheStore: Attribute not found: ${attrDef.fieldName}`);
return;
}
const attributeId = attrRow.id;
// Handle null/undefined
if (value === null || value === undefined) {
return; // Don't store null values
}
// Handle arrays
if (attrDef.isMultiple && Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
await this.insertAttributeValue(objectId, attributeId, value[i], attrDef, i);
}
return;
}
// Single value
await this.insertAttributeValue(objectId, attributeId, value, attrDef, 0);
}
/**
* Insert one attribute value row
*/
private async insertAttributeValue(
objectId: string,
attributeId: number,
value: unknown,
attrDef: AttributeDefinition,
arrayIndex: number
): Promise<void> {
switch (attrDef.type) {
case 'reference': {
const ref = value as ObjectReference;
if (ref?.objectId) {
await this.db.execute(`
INSERT INTO attribute_values (object_id, attribute_id, reference_object_id, array_index)
VALUES (?, ?, ?, ?)
`, [objectId, attributeId, ref.objectId, arrayIndex]);
}
break;
}
case 'text':
case 'textarea':
case 'url':
case 'email':
case 'select':
case 'user':
case 'status': {
const textValue = String(value);
await this.db.execute(`
INSERT INTO attribute_values (object_id, attribute_id, text_value, array_index)
VALUES (?, ?, ?, ?)
`, [objectId, attributeId, textValue, arrayIndex]);
break;
}
case 'integer':
case 'float': {
const numValue = Number(value);
if (!isNaN(numValue)) {
await this.db.execute(`
INSERT INTO attribute_values (object_id, attribute_id, number_value, array_index)
VALUES (?, ?, ?, ?)
`, [objectId, attributeId, numValue, arrayIndex]);
}
break;
}
case 'boolean': {
const boolValue = Boolean(value);
await this.db.execute(`
INSERT INTO attribute_values (object_id, attribute_id, boolean_value, array_index)
VALUES (?, ?, ?, ?)
`, [objectId, attributeId, boolValue, arrayIndex]);
break;
}
case 'date': {
const dateValue = value as string; // ISO date string
await this.db.execute(`
INSERT INTO attribute_values (object_id, attribute_id, date_value, array_index)
VALUES (?, ?, ?, ?)
`, [objectId, attributeId, dateValue, arrayIndex]);
break;
}
case 'datetime': {
const datetimeValue = value as string; // ISO datetime string
await this.db.execute(`
INSERT INTO attribute_values (object_id, attribute_id, datetime_value, array_index)
VALUES (?, ?, ?, ?)
`, [objectId, attributeId, datetimeValue, arrayIndex]);
break;
}
default: {
// Fallback to text
const textValue = String(value);
await this.db.execute(`
INSERT INTO attribute_values (object_id, attribute_id, text_value, array_index)
VALUES (?, ?, ?, ?)
`, [objectId, attributeId, textValue, arrayIndex]);
}
}
}
4. Reconstruction Logic (Step 4)
File: backend/src/services/normalizedCacheStore.ts (partial - reconstruction methods)
/**
* Reconstruct CMDBObject from normalized data
*/
private async reconstructObject<T extends CMDBObject>(
objectId: string,
typeName: CMDBObjectTypeName
): Promise<T | null> {
// 1. Get object record
const objRow = await this.db.queryOne<{
id: string;
object_key: string;
label: string;
jira_updated_at: string | null;
jira_created_at: string | null;
}>(`
SELECT id, object_key, label, jira_updated_at, jira_created_at
FROM objects
WHERE id = ? AND object_type_name = ?
`, [objectId, typeName]);
if (!objRow) return null;
// 2. Load all attribute values
const attributeValues = await this.loadAttributeValues(objectId, typeName);
// 3. Build CMDBObject
const result: Record<string, unknown> = {
id: objRow.id,
objectKey: objRow.object_key,
label: objRow.label,
_objectType: typeName,
_jiraUpdatedAt: objRow.jira_updated_at || new Date().toISOString(),
_jiraCreatedAt: objRow.jira_created_at || new Date().toISOString(),
...attributeValues
};
return result as T;
}
/**
* Load all attribute values for an object
*/
private async loadAttributeValues(
objectId: string,
typeName: CMDBObjectTypeName
): Promise<Record<string, unknown>> {
// Get all attributes for this type
const attributes = await schemaDiscoveryService.getAttributesForType(typeName);
// Get all attribute values
const valueRows = await this.db.query<{
attribute_id: number;
field_name: string;
attr_type: string;
is_multiple: boolean;
text_value: string | null;
number_value: number | null;
boolean_value: boolean | null;
date_value: string | null;
datetime_value: string | null;
reference_object_id: string | null;
array_index: number;
}>(`
SELECT
av.attribute_id,
a.field_name,
a.attr_type,
a.is_multiple,
av.text_value,
av.number_value,
av.boolean_value,
av.date_value,
av.datetime_value,
av.reference_object_id,
av.array_index
FROM attribute_values av
JOIN attributes a ON av.attribute_id = a.id
WHERE av.object_id = ?
ORDER BY a.field_name, av.array_index
`, [objectId]);
// Group by field name
const result: Record<string, unknown> = {};
for (const row of valueRows) {
const fieldName = row.field_name;
const isMultiple = row.is_multiple;
// Get value based on type
let value: unknown = null;
switch (row.attr_type) {
case 'reference':
if (row.reference_object_id) {
// Load referenced object details
const refObj = await this.getObjectById(row.reference_object_id);
if (refObj) {
value = {
objectId: refObj.id,
objectKey: refObj.objectKey,
label: refObj.label,
};
}
}
break;
case 'text':
case 'textarea':
case 'url':
case 'email':
case 'select':
case 'user':
case 'status':
value = row.text_value;
break;
case 'integer':
case 'float':
value = row.number_value;
break;
case 'boolean':
value = row.boolean_value;
break;
case 'date':
value = row.date_value;
break;
case 'datetime':
value = row.datetime_value;
break;
default:
value = row.text_value;
}
// Handle arrays vs single values
if (isMultiple) {
if (!result[fieldName]) {
result[fieldName] = [];
}
(result[fieldName] as unknown[]).push(value);
} else {
result[fieldName] = value;
}
}
return result;
}
/**
* Helper to get object by ID (for references)
*/
private async getObjectById(id: string): Promise<{
id: string;
objectKey: string;
label: string;
} | null> {
const row = await this.db.queryOne<{
id: string;
object_key: string;
label: string;
}>(`
SELECT id, object_key, label
FROM objects
WHERE id = ?
`, [id]);
return row;
}
5. Query Builder (Step 6)
File: backend/src/services/queryBuilder.ts
import { logger } from './logger.js';
import { schemaDiscoveryService } from './schemaDiscoveryService.js';
import type { CMDBObjectTypeName } from '../generated/jira-types.js';
import type { AttributeDefinition } from '../generated/jira-schema.js';
class QueryBuilder {
/**
* Build WHERE clause from filters
*/
async buildWhereClause(
filters: Record<string, unknown>,
typeName: CMDBObjectTypeName
): Promise<{ whereClause: string; params: unknown[] }> {
const conditions: string[] = ['o.object_type_name = ?'];
const params: unknown[] = [typeName];
let paramIndex = 2;
for (const [fieldName, filterValue] of Object.entries(filters)) {
if (filterValue === undefined || filterValue === null) continue;
const attrDef = await schemaDiscoveryService.getAttribute(typeName, fieldName);
if (!attrDef) {
logger.debug(`QueryBuilder: Unknown field ${fieldName} for type ${typeName}, skipping`);
continue;
}
const condition = this.buildFilterCondition(fieldName, filterValue, attrDef, paramIndex);
if (condition.condition) {
conditions.push(condition.condition);
params.push(...condition.params);
paramIndex += condition.params.length;
}
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
return { whereClause, params };
}
/**
* Build filter condition for one field
*/
buildFilterCondition(
fieldName: string,
filterValue: unknown,
attrDef: AttributeDefinition,
startParamIndex: number
): { condition: string; params: unknown[] } {
// Handle special operators
if (typeof filterValue === 'object' && filterValue !== null && !Array.isArray(filterValue)) {
const filterObj = filterValue as Record<string, unknown>;
// Exists check
if (filterObj.exists === true) {
return {
condition: `EXISTS (
SELECT 1 FROM attribute_values av
JOIN attributes a ON av.attribute_id = a.id
WHERE av.object_id = o.id AND a.field_name = ?
)`,
params: [fieldName]
};
}
// Empty check
if (filterObj.empty === true) {
return {
condition: `NOT EXISTS (
SELECT 1 FROM attribute_values av
JOIN attributes a ON av.attribute_id = a.id
WHERE av.object_id = o.id AND a.field_name = ?
)`,
params: [fieldName]
};
}
// Contains (text search)
if (filterObj.contains !== undefined && typeof filterObj.contains === 'string') {
if (attrDef.type === 'text' || attrDef.type === 'textarea') {
return {
condition: `EXISTS (
SELECT 1 FROM attribute_values av
JOIN attributes a ON av.attribute_id = a.id
WHERE av.object_id = o.id
AND a.field_name = ?
AND LOWER(av.text_value) LIKE LOWER(?)
)`,
params: [fieldName, `%${filterObj.contains}%`]
};
}
}
// Reference filters
if (attrDef.type === 'reference') {
if (filterObj.objectId !== undefined) {
return {
condition: `EXISTS (
SELECT 1 FROM attribute_values av
JOIN attributes a ON av.attribute_id = a.id
WHERE av.object_id = o.id
AND a.field_name = ?
AND av.reference_object_id = ?
)`,
params: [fieldName, String(filterObj.objectId)]
};
}
if (filterObj.objectKey !== undefined) {
// Need to join with objects table to get objectKey
return {
condition: `EXISTS (
SELECT 1 FROM attribute_values av
JOIN attributes a ON av.attribute_id = a.id
JOIN objects ref_obj ON av.reference_object_id = ref_obj.id
WHERE av.object_id = o.id
AND a.field_name = ?
AND ref_obj.object_key = ?
)`,
params: [fieldName, String(filterObj.objectKey)]
};
}
}
}
// Handle array filters (for multiple reference fields)
if (attrDef.isMultiple && Array.isArray(filterValue)) {
if (attrDef.type === 'reference') {
const conditions: string[] = [];
const params: unknown[] = [];
for (const val of filterValue) {
if (typeof val === 'object' && val !== null) {
const ref = val as { objectId?: string; objectKey?: string };
if (ref.objectId) {
conditions.push(`EXISTS (
SELECT 1 FROM attribute_values av
JOIN attributes a ON av.attribute_id = a.id
WHERE av.object_id = o.id
AND a.field_name = ?
AND av.reference_object_id = ?
)`);
params.push(fieldName, ref.objectId);
}
}
}
if (conditions.length > 0) {
return { condition: `(${conditions.join(' OR ')})`, params };
}
}
}
// Simple value filters
if (attrDef.type === 'reference') {
if (typeof filterValue === 'object' && filterValue !== null) {
const ref = filterValue as { objectId?: string; objectKey?: string; label?: string };
if (ref.objectId) {
return {
condition: `EXISTS (
SELECT 1 FROM attribute_values av
JOIN attributes a ON av.attribute_id = a.id
WHERE av.object_id = o.id
AND a.field_name = ?
AND av.reference_object_id = ?
)`,
params: [fieldName, ref.objectId]
};
}
}
} else if (attrDef.type === 'text' || attrDef.type === 'textarea') {
return {
condition: `EXISTS (
SELECT 1 FROM attribute_values av
JOIN attributes a ON av.attribute_id = a.id
WHERE av.object_id = o.id
AND a.field_name = ?
AND av.text_value = ?
)`,
params: [fieldName, String(filterValue)]
};
} else if (attrDef.type === 'integer' || attrDef.type === 'float') {
return {
condition: `EXISTS (
SELECT 1 FROM attribute_values av
JOIN attributes a ON av.attribute_id = a.id
WHERE av.object_id = o.id
AND a.field_name = ?
AND av.number_value = ?
)`,
params: [fieldName, Number(filterValue)]
};
} else if (attrDef.type === 'boolean') {
return {
condition: `EXISTS (
SELECT 1 FROM attribute_values av
JOIN attributes a ON av.attribute_id = a.id
WHERE av.object_id = o.id
AND a.field_name = ?
AND av.boolean_value = ?
)`,
params: [fieldName, Boolean(filterValue)]
};
}
return { condition: '', params: [] };
}
/**
* Build ORDER BY clause
*/
buildOrderBy(orderBy?: string, orderDir?: 'ASC' | 'DESC'): string {
const safeOrderBy = ['id', 'object_key', 'object_type_name', 'label', 'cached_at'].includes(orderBy || '')
? (orderBy || 'label')
: 'label';
const safeOrderDir = orderDir === 'DESC' ? 'DESC' : 'ASC';
return `ORDER BY o.${safeOrderBy} ${safeOrderDir}`;
}
/**
* Build pagination clause
*/
buildPagination(limit?: number, offset?: number): string {
const limitValue = limit || 100;
const offsetValue = offset || 0;
return `LIMIT ${limitValue} OFFSET ${offsetValue}`;
}
}
export const queryBuilder = new QueryBuilder();
6. Query Operations (Step 7)
File: backend/src/services/normalizedCacheStore.ts (partial - query methods)
/**
* Query objects with filters
*/
async queryWithFilters<T extends CMDBObject>(
typeName: CMDBObjectTypeName,
filters: Record<string, unknown>,
options?: QueryOptions
): Promise<{ objects: T[]; total: number }> {
await this.ensureInitialized();
// Build WHERE clause
const { whereClause, params: whereParams } = await queryBuilder.buildWhereClause(filters, typeName);
// Build ORDER BY
const orderBy = queryBuilder.buildOrderBy(options?.orderBy, options?.orderDir);
// Build pagination
const pagination = queryBuilder.buildPagination(options?.limit, options?.offset);
// Count query
const countQuery = `
SELECT COUNT(DISTINCT o.id) as count
FROM objects o
${whereClause}
`;
// Data query
const dataQuery = `
SELECT DISTINCT o.id
FROM objects o
${whereClause}
${orderBy}
${pagination}
`;
try {
// Execute both queries
const [countResult, dataResult] = await Promise.all([
this.db.queryOne<{ count: number }>(countQuery, whereParams),
this.db.query<{ id: string }>(dataQuery, whereParams),
]);
const total = countResult?.count || 0;
// Reconstruct objects
const objects = await Promise.all(
dataResult.map(row => this.reconstructObject<T>(row.id, typeName))
);
return {
objects: objects.filter((obj): obj is T => obj !== null),
total
};
} catch (error) {
logger.error(`NormalizedCacheStore: Query failed for ${typeName}`, error);
return { objects: [], total: 0 };
}
}
Implementation Checklist
Before starting implementation, verify:
- Green field confirmed (no production data)
- Database adapter supports both PostgreSQL and SQLite
- Schema discovery can read from
OBJECT_TYPES - All attribute types can be normalized
- Reconstruction logic handles all types
- Query builder supports all filter types
- Interface matches existing
cacheStoreexactly
Critical Implementation Notes
-
Schema Discovery Must Run First
- Call
schemaDiscoveryService.discoverAndStoreSchema()before any operations - Can be done in
initialize()method
- Call
-
Reference Loading
- When reconstructing references, may need to load referenced objects
- Consider caching referenced objects to avoid N+1 queries
-
Transaction Safety
- Use transactions for
upsertObject()to ensure atomicity - Delete old values before inserting new ones
- Use transactions for
-
Error Handling
- Handle missing attributes gracefully
- Log warnings but don't fail entire operation
-
Performance
- Batch operations where possible
- Use indexes effectively
- Consider caching attribute definitions
End of Plan