- Implement OAuth 2.0 and PAT authentication methods - Add user management, roles, and profile functionality - Add database migrations and admin user scripts - Update services for authentication and user settings - Add protected routes and permission hooks - Update documentation for authentication and database access
27 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
End of Plan