Files
cmdb-insight/docs/NORMALIZED-DATABASE-IMPLEMENTATION-PLAN.md
Bert Hausmans cdee0e8819 UI styling improvements: dashboard headers and navigation
- 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
2026-01-21 03:24:56 +01:00

2131 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
1. [Architecture Overview](#architecture-overview)
2. [Database Schema](#database-schema)
3. [Implementation Components](#implementation-components)
4. [Implementation Steps](#implementation-steps)
5. [Code Structure](#code-structure)
6. [Testing Strategy](#testing-strategy)
7. [Migration Path](#migration-path)
8. [Example Implementations](#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
1. **Generic**: Works with any Jira Assets schema (discovered dynamically)
2. **Efficient**: Database-level filtering with indexes
3. **Scalable**: Handles large datasets efficiently
4. **Type-safe**: Proper data types per attribute
5. **Queryable**: Complex filters at database level
---
## Database Schema
### Tables
#### 1. `object_types`
Stores discovered object types from Jira schema.
```sql
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.
```sql
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.
```sql
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.
```sql
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.
```sql
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.
```sql
CREATE TABLE sync_metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
### Indexes
**Critical for query performance:**
```sql
-- 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:**
```typescript
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_TYPES` in `generated/jira-schema.ts`
- Populates `object_types` and `attributes` tables
- 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
```typescript
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
```typescript
/**
* 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
```typescript
/**
* 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
```typescript
/**
* 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:**
```typescript
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:**
1. **Exact match:**
```typescript
{ status: "Active" }
```
2. **Contains (text):**
```typescript
{ name: { contains: "search" } }
```
3. **Reference match:**
```typescript
{ governanceModel: { objectId: "123" } }
{ governanceModel: { objectKey: "GOV-A" } }
{ governanceModel: { label: "Model A" } }
```
4. **Array contains:**
```typescript
{ applicationFunction: [
{ objectId: "1" },
{ objectId: "2" }
]}
```
5. **Exists:**
```typescript
{ applicationFunction: { exists: true } }
```
6. **Empty:**
```typescript
{ applicationFunction: { empty: true } }
```
### 4. Updated Services
#### syncEngine.ts
- Update `syncObjectType()` to use `normalizedCacheStore`
- Keep same interface, different implementation
- No changes to sync logic, only storage layer
#### dataService.ts
- Update `searchApplications()` to use `queryWithFilters()`
- Remove JavaScript filtering logic
- Use SQL queries instead
- Much faster for complex filters
#### cmdbService.ts
- Update to use `normalizedCacheStore` instead of `cacheStore`
- 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:**
1. Create schema SQL for PostgreSQL
2. Create schema SQL for SQLite (development)
3. Create migration function
4. Test schema creation
**Deliverables:**
- Schema SQL files
- Migration function
- Tests pass
### Step 2: Schema Discovery Service
**File:** `backend/src/services/schemaDiscoveryService.ts`
**Tasks:**
1. Implement `discoverAndStoreSchema()` - Read from `OBJECT_TYPES`
2. Populate `object_types` table
3. Populate `attributes` table
4. Add validation and error handling
5. 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:**
1. Implement `normalizeObject()` - Convert CMDBObject to normalized form
2. Handle all attribute types:
- Text (text, textarea, url, email, select, user, status)
- Numbers (integer, float)
- Boolean
- Dates (date, datetime)
- References (single and multiple)
3. Implement `storeAttributeValue()` - Store values in correct columns
4. Handle arrays (multiple rows with array_index)
5. 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:**
1. Implement `reconstructObject()` - Build CMDBObject from normalized data
2. Load all attribute values for object
3. Convert back to TypeScript types
4. Handle arrays (multiple rows)
5. Handle references (load referenced objects if needed)
6. 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:**
1. Implement `upsertObject()` - Normalize and store
2. Implement `getObject()` - Reconstruct and return
3. Implement `getObjects()` - Basic query (no filters yet)
4. Implement `deleteObject()` - Delete object and values
5. Implement `countObjects()` - Count by type
6. Test CRUD operations
**Deliverables:**
- CRUD operations working
- Tests pass
- Data integrity verified
### Step 6: Generic Query Builder
**File:** `backend/src/services/queryBuilder.ts`
**Tasks:**
1. Implement filter condition builder
2. Support all filter types:
- Exact match
- Contains
- Reference match
- Array contains
- Exists
- Empty
3. Build WHERE clauses dynamically
4. Handle JOINs for attribute values
5. 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:**
1. Implement `queryWithFilters()` - Use query builder
2. Implement `countWithFilters()` - Count with same filters
3. Add pagination support
4. Add sorting support
5. 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:**
1. Update `extractAndStoreRelations()` - Use attribute_id
2. Update `getRelatedObjects()` - Use normalized queries
3. Update `getReferencingObjects()` - Use normalized queries
4. Test relations
**Deliverables:**
- Relations working
- Tests pass
- Performance acceptable
### Step 9: Update Services
**Files:**
- `backend/src/services/syncEngine.ts`
- `backend/src/services/cmdbService.ts`
- `backend/src/services/dataService.ts`
**Tasks:**
1. Replace `cacheStore` imports with `normalizedCacheStore`
2. Update `dataService.searchApplications()` - Use `queryWithFilters()`
3. Remove JavaScript filtering logic
4. Update all service calls
5. Test all endpoints
**Deliverables:**
- All services updated
- All endpoints working
- Tests pass
### Step 10: Statistics & Utilities
**File:** `backend/src/services/normalizedCacheStore.ts` (partial)
**Tasks:**
1. Implement `getStats()` - Count from normalized tables
2. Implement `isWarm()` - Check if cache has data
3. Implement `clearObjectType()` - Clear type and values
4. Implement `clearAll()` - Clear all data
5. Test statistics
**Deliverables:**
- Statistics working
- Tests pass
- Performance acceptable
### Step 11: Remove Old Code
**Files:**
- `backend/src/services/cacheStore.ts` - Delete or archive
**Tasks:**
1. Remove old `cacheStore.ts`
2. Update all imports
3. Clean up unused code
4. 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
```typescript
// 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
1. **Schema Discovery**
- Test schema population
- Test attribute lookup
- Test object type lookup
- Test idempotency
2. **Normalization**
- Test all attribute types (text, number, boolean, date, reference)
- Test arrays (multiple values)
- Test null/empty values
- Test edge cases
3. **Reconstruction**
- Test object reconstruction
- Test all attribute types
- Test arrays
- Test references
- Test missing values
4. **Query Builder**
- Test all filter types
- Test complex filters
- Test SQL generation
- Test parameter binding
### Integration Tests
1. **CRUD Operations**
- Create, read, update, delete
- Verify data integrity
- Test transactions
2. **Queries**
- Simple filters
- Complex filters (multiple conditions)
- Pagination
- Sorting
- Performance with large datasets
3. **Relations**
- Store relations
- Query relations
- Delete relations
- Cascade deletes
### Performance Tests
1. **Query Performance**
- Compare old vs new
- Test with 500+ objects
- Test complex filters
- Measure query time
2. **Write Performance**
- Batch inserts
- Single inserts
- Updates
- Measure write time
3. **Memory Usage**
- Compare old vs new
- Test with large datasets
- Measure memory footprint
---
## Migration Path
### Since it's Green Field
1. **No Data Migration Needed**
- Start fresh with normalized structure
- No existing data to migrate
- Clean implementation
2. **Implementation Order**
- Build new normalized structure
- Test thoroughly
- Replace old code
- Deploy
3. **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):**
```typescript
{
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):**
```sql
-- 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:**
```typescript
{
status: "Active",
governanceModel: { objectId: "999" },
applicationFunction: { exists: true }
}
```
**Generated SQL:**
```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:**
```sql
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:**
```typescript
// 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
1. ✅ Review and approve plan
2. ✅ Create feature branch: `feature/normalized-database`
3. ✅ Start implementation (Step 1)
4. ✅ Daily progress updates
5. ✅ 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:
1. **Green Field Status**
- No production data exists
- No migration needed
- Clean implementation possible
2. **Interface Compatibility**
- New `normalizedCacheStore` will implement exact same interface as `cacheStore`
- Same method signatures
- Same return types
- Drop-in replacement
3. **Limited Dependencies**
- Only 4 files import `cacheStore`:
- `syncEngine.ts`
- `cmdbService.ts`
- `dataService.ts`
- `routes/cache.ts`
- Easy to replace all at once
4. **Separate Database**
- Classifications database (`database.ts`) is separate
- Won't be affected by changes
- No risk to existing data
5. **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:
```typescript
// 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`
```typescript
/**
* 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`
```typescript
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)
```typescript
/**
* 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)
```typescript
/**
* 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`
```typescript
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)
```typescript
/**
* 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 `cacheStore` exactly
### Critical Implementation Notes
1. **Schema Discovery Must Run First**
- Call `schemaDiscoveryService.discoverAndStoreSchema()` before any operations
- Can be done in `initialize()` method
2. **Reference Loading**
- When reconstructing references, may need to load referenced objects
- Consider caching referenced objects to avoid N+1 queries
3. **Transaction Safety**
- Use transactions for `upsertObject()` to ensure atomicity
- Delete old values before inserting new ones
4. **Error Handling**
- Handle missing attributes gracefully
- Log warnings but don't fail entire operation
5. **Performance**
- Batch operations where possible
- Use indexes effectively
- Consider caching attribute definitions
---
**End of Plan**