refactor(sync): centralize entity configuration in single registry

Create central entity registry to eliminate scattered configuration
across 7+ files. This reduces technical debt and makes adding new
entity types easier.

Changes:
- Create entity-registry.ts with ENTITY_CONFIGS containing all 17
  entity types with their adapters, selectors, and dependencies
- Document storage patterns (adapter, singleton, map, array, virtual)
- Migrate lww-update.meta-reducer.ts to use registry
- Migrate dependency-resolver.service.ts to use registry
- Migrate conflict-resolution.service.ts to use registry (removes
  80-line switch statement)
- Migrate validate-operation-payload.ts to use getAllPayloadKeys()
- Add ESLint rule require-entity-registry to detect missing entity
  types and typos in entityType properties

Net reduction: 112 lines of code
This commit is contained in:
Johannes Millan 2025-12-23 16:15:17 +01:00
parent 3caad75980
commit 58372626f1
9 changed files with 779 additions and 305 deletions

View file

@ -26,6 +26,7 @@
"plugins": ["@typescript-eslint", "prettier", "prefer-arrow", "local-rules"],
"rules": {
"local-rules/require-hydration-guard": "warn",
"local-rules/require-entity-registry": "warn",
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/ban-ts-comment": 0,
"@typescript-eslint/no-empty-function": 0,

View file

@ -4,8 +4,12 @@
* These rules are loaded by eslint-plugin-local-rules.
* Usage in .eslintrc.json:
* "plugins": ["local-rules"],
* "rules": { "local-rules/require-hydration-guard": "warn" }
* "rules": {
* "local-rules/require-hydration-guard": "warn",
* "local-rules/require-entity-registry": "warn"
* }
*/
module.exports = {
'require-hydration-guard': require('./rules/require-hydration-guard'),
'require-entity-registry': require('./rules/require-entity-registry'),
};

View file

@ -0,0 +1,224 @@
/**
* ESLint rule: require-entity-registry
*
* Validates that entity types are properly registered in ENTITY_CONFIGS.
*
* ## What This Rule Checks
*
* 1. When ENTITY_CONFIGS object is defined, verifies expected entity types are present
* 2. Detects unknown entity type strings (typos)
*
* ## Why This Matters
*
* The operation log system requires entity configuration in ENTITY_CONFIGS.
* Missing configurations cause silent failures during sync, LWW resolution,
* and dependency checking.
*
* ## Usage
*
* Add to .eslintrc.json:
* "local-rules/require-entity-registry": "warn"
*/
// All valid entity types from operation.types.ts
// Keep in sync with EntityType union
const VALID_ENTITY_TYPES = new Set([
'TASK',
'PROJECT',
'TAG',
'NOTE',
'GLOBAL_CONFIG',
'SIMPLE_COUNTER',
'WORK_CONTEXT',
'TIME_TRACKING',
'TASK_REPEAT_CFG',
'ISSUE_PROVIDER',
'PLANNER',
'MENU_TREE',
'METRIC',
'BOARD',
'REMINDER',
'PLUGIN_USER_DATA',
'PLUGIN_METADATA',
'MIGRATION',
'RECOVERY',
'ALL',
]);
// Entity types that MUST be configured in ENTITY_CONFIGS
// Excludes special types: ALL, RECOVERY, MIGRATION (not stored in NgRx)
const REQUIRED_ENTITY_TYPES = new Set([
'TASK',
'PROJECT',
'TAG',
'NOTE',
'GLOBAL_CONFIG',
'SIMPLE_COUNTER',
'WORK_CONTEXT',
'TIME_TRACKING',
'TASK_REPEAT_CFG',
'ISSUE_PROVIDER',
'PLANNER',
'MENU_TREE',
'METRIC',
'BOARD',
'REMINDER',
'PLUGIN_USER_DATA',
'PLUGIN_METADATA',
]);
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Validate entity types are registered in ENTITY_CONFIGS',
category: 'Possible Errors',
recommended: true,
},
messages: {
missingEntityType:
'ENTITY_CONFIGS is missing entity type "{{entityType}}". ' +
'Add configuration for this entity type to ensure proper sync behavior.',
unknownEntityType:
'Unknown entity type "{{entityType}}" in {{context}}. ' +
'Valid types: TASK, PROJECT, TAG, NOTE, etc. Check for typos.',
},
schema: [],
},
create(context) {
/**
* Get the variable name from a VariableDeclarator
*/
const getVariableName = (node) => {
if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier') {
return node.id.name;
}
return null;
};
/**
* Extract keys from an object expression that look like entity types
*/
const extractEntityTypeKeys = (objectExpr) => {
const keys = new Set();
if (objectExpr.type !== 'ObjectExpression') return keys;
for (const prop of objectExpr.properties) {
if (prop.type === 'Property' || prop.type === 'PropertyDefinition') {
const key = prop.key;
if (key.type === 'Identifier') {
keys.add(key.name);
} else if (key.type === 'Literal' && typeof key.value === 'string') {
keys.add(key.value);
}
}
}
return keys;
};
/**
* Check if a set of keys looks like an entity registry
* (has at least 3 valid entity type keys)
*/
const looksLikeEntityRegistry = (keys) => {
let validCount = 0;
for (const key of keys) {
if (VALID_ENTITY_TYPES.has(key)) {
validCount++;
}
}
return validCount >= 3;
};
return {
// Check ENTITY_CONFIGS definition
VariableDeclarator(node) {
const name = getVariableName(node);
if (name !== 'ENTITY_CONFIGS') return;
const init = node.init;
if (!init || init.type !== 'ObjectExpression') return;
const presentKeys = extractEntityTypeKeys(init);
// Check for missing required types
for (const entityType of REQUIRED_ENTITY_TYPES) {
if (!presentKeys.has(entityType)) {
context.report({
node: init,
messageId: 'missingEntityType',
data: { entityType },
});
}
}
// Check for unknown types (typos)
for (const key of presentKeys) {
if (!VALID_ENTITY_TYPES.has(key)) {
context.report({
node: init,
messageId: 'unknownEntityType',
data: { entityType: key, context: 'ENTITY_CONFIGS' },
});
}
}
},
// Check entityType property assignments for typos
Property(node) {
// Only check if key is 'entityType'
if (node.key.type !== 'Identifier' || node.key.name !== 'entityType') {
return;
}
// Check if value is a string literal
if (node.value.type === 'Literal' && typeof node.value.value === 'string') {
const value = node.value.value;
if (!VALID_ENTITY_TYPES.has(value)) {
context.report({
node: node.value,
messageId: 'unknownEntityType',
data: { entityType: value, context: 'entityType property' },
});
}
}
},
// Check switch case statements on entityType
SwitchCase(node) {
if (!node.test || node.test.type !== 'Literal') return;
if (typeof node.test.value !== 'string') return;
// Check if parent switch is on entityType variable
const switchStmt = node.parent;
if (!switchStmt || switchStmt.type !== 'SwitchStatement') return;
const discriminant = switchStmt.discriminant;
let isEntityTypeSwitch = false;
// Check for switch(entityType) or switch(op.entityType)
if (discriminant.type === 'Identifier' && discriminant.name === 'entityType') {
isEntityTypeSwitch = true;
} else if (
discriminant.type === 'MemberExpression' &&
discriminant.property.type === 'Identifier' &&
discriminant.property.name === 'entityType'
) {
isEntityTypeSwitch = true;
}
if (isEntityTypeSwitch) {
const value = node.test.value;
if (!VALID_ENTITY_TYPES.has(value)) {
context.report({
node: node.test,
messageId: 'unknownEntityType',
data: { entityType: value, context: 'switch case' },
});
}
}
},
};
},
};

View file

@ -0,0 +1,420 @@
/**
* Central Entity Registry for Operation Log System
*
* Simple config objects - single source of truth for entity metadata.
*
* ## Adding a New Entity Type:
* 1. Add the type to EntityType union in operation.types.ts
* 2. Add config here
* 3. Run `npm run checkFile src/app/core/persistence/operation-log/entity-registry.ts`
*/
import { EntityAdapter } from '@ngrx/entity';
import { createSelector } from '@ngrx/store';
import { Dictionary } from '@ngrx/entity';
import { EntityType } from './operation.types';
// ─────────────────────────────────────────────────────────────────────────────
// IMPORTS - Adapters & Feature Names
// ─────────────────────────────────────────────────────────────────────────────
import {
TASK_FEATURE_NAME,
taskAdapter,
} from '../../../features/tasks/store/task.reducer';
import {
PROJECT_FEATURE_NAME,
projectAdapter,
} from '../../../features/project/store/project.reducer';
import { TAG_FEATURE_NAME, tagAdapter } from '../../../features/tag/store/tag.reducer';
import {
adapter as noteAdapter,
NOTE_FEATURE_NAME,
} from '../../../features/note/store/note.reducer';
import {
adapter as simpleCounterAdapter,
SIMPLE_COUNTER_FEATURE_NAME,
} from '../../../features/simple-counter/store/simple-counter.reducer';
import {
adapter as taskRepeatCfgAdapter,
TASK_REPEAT_CFG_FEATURE_NAME,
} from '../../../features/task-repeat-cfg/store/task-repeat-cfg.selectors';
import {
metricAdapter,
METRIC_FEATURE_NAME,
} from '../../../features/metric/store/metric.reducer';
import {
adapter as issueProviderAdapter,
ISSUE_PROVIDER_FEATURE_KEY,
} from '../../../features/issue/store/issue-provider.reducer';
import { CONFIG_FEATURE_NAME } from '../../../features/config/store/global-config.reducer';
import { TIME_TRACKING_FEATURE_KEY } from '../../../features/time-tracking/store/time-tracking.reducer';
import { plannerFeatureKey } from '../../../features/planner/store/planner.reducer';
import { BOARDS_FEATURE_NAME } from '../../../features/boards/store/boards.reducer';
import { menuTreeFeatureKey } from '../../../features/menu-tree/store/menu-tree.reducer';
import { REMINDER_FEATURE_NAME } from '../../../features/reminder/store/reminder.reducer';
// ─────────────────────────────────────────────────────────────────────────────
// IMPORTS - Selectors
// ─────────────────────────────────────────────────────────────────────────────
import {
selectTaskEntities,
selectTaskById,
} from '../../../features/tasks/store/task.selectors';
import {
selectProjectFeatureState,
selectEntities as selectProjectEntitiesFromAdapter,
} from '../../../features/project/store/project.reducer';
import { selectProjectById } from '../../../features/project/store/project.selectors';
import {
selectTagFeatureState,
selectEntities as selectTagEntitiesFromAdapter,
selectTagById,
} from '../../../features/tag/store/tag.reducer';
import {
selectNoteFeatureState,
selectEntities as selectNoteEntitiesFromAdapter,
selectNoteById,
} from '../../../features/note/store/note.reducer';
import {
selectSimpleCounterFeatureState,
selectEntities as selectSimpleCounterEntitiesFromAdapter,
selectSimpleCounterById,
} from '../../../features/simple-counter/store/simple-counter.reducer';
import {
selectTaskRepeatCfgFeatureState,
selectTaskRepeatCfgById,
} from '../../../features/task-repeat-cfg/store/task-repeat-cfg.selectors';
import {
selectMetricFeatureState,
selectEntities as selectMetricEntitiesFromAdapter,
selectMetricById,
} from '../../../features/metric/store/metric.selectors';
import {
selectIssueProviderState,
selectEntities as selectIssueProviderEntitiesFromAdapter,
selectIssueProviderById,
} from '../../../features/issue/store/issue-provider.selectors';
import { selectConfigFeatureState } from '../../../features/config/store/global-config.reducer';
import { selectTimeTrackingState } from '../../../features/time-tracking/store/time-tracking.selectors';
import { selectPlannerState } from '../../../features/planner/store/planner.selectors';
import { selectBoardsState } from '../../../features/boards/store/boards.selectors';
import { selectMenuTreeState } from '../../../features/menu-tree/store/menu-tree.selectors';
import { selectReminderFeatureState } from '../../../features/reminder/store/reminder.reducer';
import {
selectContextFeatureState,
WORK_CONTEXT_FEATURE_NAME,
} from '../../../features/work-context/store/work-context.selectors';
// ─────────────────────────────────────────────────────────────────────────────
// TYPES
// ─────────────────────────────────────────────────────────────────────────────
/**
* Storage patterns for entity types in NgRx state.
*
* - `adapter`: Uses NgRx EntityAdapter for normalized entity collections.
* State shape: `{ ids: string[], entities: { [id]: Entity } }`
* Examples: TASK, PROJECT, TAG, NOTE, SIMPLE_COUNTER, METRIC, ISSUE_PROVIDER
*
* - `singleton`: Entire feature state is one object (no entity collection).
* State shape: The full feature state object
* Examples: GLOBAL_CONFIG, TIME_TRACKING, MENU_TREE, WORK_CONTEXT
*
* - `map`: State contains a map/dictionary keyed by some identifier.
* State shape: `{ [mapKey]: { [key]: value } }`
* Examples: PLANNER (days map keyed by date string)
*
* - `array`: State contains an array of items (not using EntityAdapter).
* State shape: Array or `{ [arrayKey]: Array }`
* Examples: BOARD (boardCfgs array), REMINDER (state IS the array)
*
* - `virtual`: Not stored in NgRx state. Used for operation types that don't
* correspond to actual state slices.
* Examples: PLUGIN_USER_DATA, PLUGIN_METADATA
*/
export type EntityStoragePattern = 'adapter' | 'singleton' | 'map' | 'array' | 'virtual';
export interface EntityDependency {
dependsOn: EntityType;
payloadField: string;
isHard: boolean;
relation: 'parent' | 'reference';
}
/**
* Base entity interface - all NgRx entities have an id.
*/
export interface BaseEntity {
id: string;
}
/**
* State selector function type compatible with store.select().
*/
export type StateSelector<T = unknown> = (state: object) => T;
/**
* Factory function that creates a selector for a specific entity by ID.
* Some entities (like ISSUE_PROVIDER) use this pattern.
*/
export type SelectByIdFactory = (id: string, key?: unknown) => StateSelector;
/**
* Configuration for a single entity type in the operation log system.
* Uses `unknown` for adapter/selector types to accommodate NgRx's complex
* generic types while maintaining runtime safety via storagePattern checks.
*/
export interface EntityConfig {
storagePattern: EntityStoragePattern;
featureName?: string;
payloadKey: string;
/** NgRx EntityAdapter - cast to specific type at usage site */
adapter?: EntityAdapter<BaseEntity>;
/** Selector returning entity dictionary - works with store.select() */
selectEntities?: StateSelector<Dictionary<BaseEntity>>;
/** Selector or factory for single entity by ID */
selectById?: StateSelector | SelectByIdFactory;
/** Selector for full feature state (singleton/map/array patterns) */
selectState?: StateSelector;
/** Key within state for map storage pattern (e.g., 'days' for PLANNER) */
mapKey?: string;
/** Key within state for array storage pattern (null = state IS the array) */
arrayKey?: string | null;
/** Entity dependencies for operation ordering */
dependencies?: EntityDependency[];
}
// ─────────────────────────────────────────────────────────────────────────────
// ENTITY CONFIGS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Central registry of all entity configurations.
*
* Type assertion is used because NgRx's complex generic types (MemoizedSelector,
* MemoizedSelectorWithProps, EntityAdapter) have variance that makes them
* incompatible with a common interface without explicit casts at each usage.
* Runtime safety is maintained via storagePattern checks at usage sites.
*/
export const ENTITY_CONFIGS = {
// ── ADAPTER ENTITIES ───────────────────────────────────────────────────────
TASK: {
storagePattern: 'adapter',
featureName: TASK_FEATURE_NAME,
payloadKey: 'task',
adapter: taskAdapter,
selectEntities: selectTaskEntities,
selectById: selectTaskById,
dependencies: [
{
dependsOn: 'PROJECT',
payloadField: 'projectId',
isHard: false,
relation: 'reference',
},
{ dependsOn: 'TASK', payloadField: 'parentId', isHard: true, relation: 'parent' },
{
dependsOn: 'TASK',
payloadField: 'subTaskIds',
isHard: false,
relation: 'reference',
},
],
},
PROJECT: {
storagePattern: 'adapter',
featureName: PROJECT_FEATURE_NAME,
payloadKey: 'project',
adapter: projectAdapter,
selectEntities: createSelector(
selectProjectFeatureState,
selectProjectEntitiesFromAdapter,
),
selectById: selectProjectById,
},
TAG: {
storagePattern: 'adapter',
featureName: TAG_FEATURE_NAME,
payloadKey: 'tag',
adapter: tagAdapter,
selectEntities: createSelector(selectTagFeatureState, selectTagEntitiesFromAdapter),
selectById: selectTagById,
dependencies: [
{
dependsOn: 'TASK',
payloadField: 'taskIds',
isHard: false,
relation: 'reference',
},
],
},
NOTE: {
storagePattern: 'adapter',
featureName: NOTE_FEATURE_NAME,
payloadKey: 'note',
adapter: noteAdapter,
selectEntities: createSelector(selectNoteFeatureState, selectNoteEntitiesFromAdapter),
selectById: selectNoteById,
dependencies: [
{
dependsOn: 'PROJECT',
payloadField: 'projectId',
isHard: false,
relation: 'reference',
},
],
},
SIMPLE_COUNTER: {
storagePattern: 'adapter',
featureName: SIMPLE_COUNTER_FEATURE_NAME,
payloadKey: 'simpleCounter',
adapter: simpleCounterAdapter,
selectEntities: createSelector(
selectSimpleCounterFeatureState,
selectSimpleCounterEntitiesFromAdapter,
),
selectById: selectSimpleCounterById,
},
TASK_REPEAT_CFG: {
storagePattern: 'adapter',
featureName: TASK_REPEAT_CFG_FEATURE_NAME,
payloadKey: 'taskRepeatCfg',
adapter: taskRepeatCfgAdapter,
selectEntities: createSelector(
selectTaskRepeatCfgFeatureState,
(s: { entities: Dictionary<unknown> }) => s.entities,
),
selectById: selectTaskRepeatCfgById,
},
METRIC: {
storagePattern: 'adapter',
featureName: METRIC_FEATURE_NAME,
payloadKey: 'metric',
adapter: metricAdapter,
selectEntities: createSelector(
selectMetricFeatureState,
selectMetricEntitiesFromAdapter,
),
selectById: selectMetricById,
},
ISSUE_PROVIDER: {
storagePattern: 'adapter',
featureName: ISSUE_PROVIDER_FEATURE_KEY,
payloadKey: 'issueProvider',
adapter: issueProviderAdapter,
selectEntities: createSelector(
selectIssueProviderState,
selectIssueProviderEntitiesFromAdapter,
),
selectById: selectIssueProviderById,
},
// ── SINGLETON ENTITIES ─────────────────────────────────────────────────────
GLOBAL_CONFIG: {
storagePattern: 'singleton',
featureName: CONFIG_FEATURE_NAME,
payloadKey: 'globalConfig',
selectState: selectConfigFeatureState,
},
TIME_TRACKING: {
storagePattern: 'singleton',
featureName: TIME_TRACKING_FEATURE_KEY,
payloadKey: 'timeTracking',
selectState: selectTimeTrackingState,
},
MENU_TREE: {
storagePattern: 'singleton',
featureName: menuTreeFeatureKey,
payloadKey: 'menuTree',
selectState: selectMenuTreeState,
},
WORK_CONTEXT: {
storagePattern: 'singleton',
featureName: WORK_CONTEXT_FEATURE_NAME,
payloadKey: 'workContext',
selectState: selectContextFeatureState,
},
// ── MAP ENTITIES ───────────────────────────────────────────────────────────
PLANNER: {
storagePattern: 'map',
featureName: plannerFeatureKey,
payloadKey: 'planner',
selectState: selectPlannerState,
mapKey: 'days',
},
// ── ARRAY ENTITIES ─────────────────────────────────────────────────────────
BOARD: {
storagePattern: 'array',
featureName: BOARDS_FEATURE_NAME,
payloadKey: 'board',
selectState: selectBoardsState,
arrayKey: 'boardCfgs',
},
REMINDER: {
storagePattern: 'array',
featureName: REMINDER_FEATURE_NAME,
payloadKey: 'reminder',
selectState: selectReminderFeatureState,
arrayKey: null, // State IS the array
},
// ── VIRTUAL ENTITIES ───────────────────────────────────────────────────────
PLUGIN_USER_DATA: {
storagePattern: 'virtual',
payloadKey: 'pluginUserData',
},
PLUGIN_METADATA: {
storagePattern: 'virtual',
payloadKey: 'pluginMetadata',
},
// Note: ALL, RECOVERY, MIGRATION are not configured - they're special operation types
} as unknown as Partial<Record<EntityType, EntityConfig>>;
// ─────────────────────────────────────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────────────────────────────────────
export const getEntityConfig = (entityType: EntityType): EntityConfig | undefined =>
ENTITY_CONFIGS[entityType];
export const getPayloadKey = (entityType: EntityType): string | undefined =>
ENTITY_CONFIGS[entityType]?.payloadKey;
export const isAdapterEntity = (config: EntityConfig): boolean =>
config.storagePattern === 'adapter';
export const isSingletonEntity = (config: EntityConfig): boolean =>
config.storagePattern === 'singleton';
export const isMapEntity = (config: EntityConfig): boolean =>
config.storagePattern === 'map';
export const isArrayEntity = (config: EntityConfig): boolean =>
config.storagePattern === 'array';
export const isVirtualEntity = (config: EntityConfig): boolean =>
config.storagePattern === 'virtual';
/**
* Returns all payload keys from configured entities.
* Useful for finding entities in payloads when the exact type is unknown.
*/
export const getAllPayloadKeys = (): string[] =>
Object.values(ENTITY_CONFIGS)
.map((config) => config?.payloadKey)
.filter((key): key is string => !!key);

View file

@ -6,6 +6,7 @@ import {
EntityChange,
} from '../operation.types';
import { OpLog } from '../../../log';
import { getPayloadKey, getAllPayloadKeys } from '../entity-registry';
/**
* Result of validating an operation payload.
@ -18,42 +19,18 @@ export interface PayloadValidationResult {
/**
* Maps EntityType to the expected payload key.
* Uses the central entity registry.
*/
const getEntityKeyFromType = (entityType: EntityType): string | null => {
const mapping: Record<string, string> = {
TASK: 'task',
PROJECT: 'project',
TAG: 'tag',
NOTE: 'note',
GLOBAL_CONFIG: 'globalConfig',
SIMPLE_COUNTER: 'simpleCounter',
WORK_CONTEXT: 'workContext',
TASK_REPEAT_CFG: 'taskRepeatCfg',
ISSUE_PROVIDER: 'issueProvider',
PLANNER: 'planner',
PLUGIN_USER_DATA: 'pluginUserData',
PLUGIN_METADATA: 'pluginMetadata',
};
return mapping[entityType] || null;
return getPayloadKey(entityType) ?? null;
};
/**
* Attempts to find an entity-like object in the payload.
* Used when the entity key doesn't match the expected pattern.
* Uses payload keys from the central entity registry.
*/
const findEntityInPayload = (payload: Record<string, unknown>): unknown => {
const entityKeys = [
'task',
'project',
'tag',
'note',
'simpleCounter',
'workContext',
'taskRepeatCfg',
'issueProvider',
];
for (const key of entityKeys) {
for (const key of getAllPayloadKeys()) {
if (payload[key] && typeof payload[key] === 'object') {
return payload[key];
}

View file

@ -24,20 +24,14 @@ import {
import { uuidv7 } from '../../../../util/uuid-v7';
import { CURRENT_SCHEMA_VERSION } from '../store/schema-migration.service';
import { CLIENT_ID_PROVIDER } from '../client-id.provider';
import { selectTaskById } from '../../../../features/tasks/store/task.selectors';
import { selectProjectById } from '../../../../features/project/store/project.selectors';
import { selectTagById } from '../../../../features/tag/store/tag.reducer';
import { selectNoteById } from '../../../../features/note/store/note.reducer';
import { selectConfigFeatureState } from '../../../../features/config/store/global-config.reducer';
import { selectSimpleCounterById } from '../../../../features/simple-counter/store/simple-counter.reducer';
import { selectTaskRepeatCfgById } from '../../../../features/task-repeat-cfg/store/task-repeat-cfg.selectors';
import {
getEntityConfig,
isAdapterEntity,
isSingletonEntity,
isMapEntity,
isArrayEntity,
} from '../entity-registry';
import { selectIssueProviderById } from '../../../../features/issue/store/issue-provider.selectors';
import { selectMetricById } from '../../../../features/metric/store/metric.selectors';
import { selectPlannerState } from '../../../../features/planner/store/planner.selectors';
import { selectBoardsState } from '../../../../features/boards/store/boards.selectors';
import { selectReminderFeatureState } from '../../../../features/reminder/store/reminder.reducer';
import { selectTimeTrackingState } from '../../../../features/time-tracking/store/time-tracking.selectors';
import { selectMenuTreeState } from '../../../../features/menu-tree/store/menu-tree.selectors';
/**
* Represents the result of LWW (Last-Write-Wins) conflict resolution.
@ -598,6 +592,7 @@ export class ConflictResolutionService {
/**
* Gets the current state of an entity from the NgRx store.
* Uses the entity registry to look up the appropriate selector.
*
* @param entityType - The type of entity
* @param entityId - The ID of the entity
@ -607,85 +602,59 @@ export class ConflictResolutionService {
entityType: EntityType,
entityId: string,
): Promise<unknown> {
const config = getEntityConfig(entityType);
if (!config) {
OpLog.warn(
`ConflictResolutionService: No config for entity type ${entityType}, falling back to remote`,
);
return undefined;
}
try {
switch (entityType) {
// Entities with direct selectById selectors
case 'TASK':
return await firstValueFrom(
this.store.select(selectTaskById, { id: entityId }),
);
case 'PROJECT':
return await firstValueFrom(
this.store.select(selectProjectById, { id: entityId }),
);
case 'TAG':
return await firstValueFrom(this.store.select(selectTagById, { id: entityId }));
case 'NOTE':
return await firstValueFrom(
this.store.select(selectNoteById, { id: entityId }),
);
case 'SIMPLE_COUNTER':
return await firstValueFrom(
this.store.select(selectSimpleCounterById, { id: entityId }),
);
case 'TASK_REPEAT_CFG':
return await firstValueFrom(
this.store.select(selectTaskRepeatCfgById, { id: entityId }),
);
case 'ISSUE_PROVIDER':
// selectIssueProviderById is a factory function: (id, key) => selector
// Adapter entities - use selectById
if (isAdapterEntity(config) && config.selectById) {
// Special case: ISSUE_PROVIDER has a factory selector (id, key) => selector
if (entityType === 'ISSUE_PROVIDER') {
return await firstValueFrom(
this.store.select(selectIssueProviderById(entityId, null)),
);
case 'METRIC':
return await firstValueFrom(
this.store.select(selectMetricById, { id: entityId }),
);
// Singleton entities (entire feature state)
case 'GLOBAL_CONFIG':
return await firstValueFrom(this.store.select(selectConfigFeatureState));
case 'TIME_TRACKING':
return await firstValueFrom(this.store.select(selectTimeTrackingState));
case 'MENU_TREE':
return await firstValueFrom(this.store.select(selectMenuTreeState));
// Complex state entities - extract entity from feature state
case 'PLANNER': {
const plannerState = await firstValueFrom(
this.store.select(selectPlannerState),
);
// Planner stores days as a map, entityId is the day string
return plannerState?.days?.[entityId];
}
case 'BOARD': {
const boardsState = await firstValueFrom(this.store.select(selectBoardsState));
// Boards state has boardCfgs array, find by id
return boardsState?.boardCfgs?.find((b: { id: string }) => b.id === entityId);
}
case 'REMINDER': {
const reminderState = await firstValueFrom(
this.store.select(selectReminderFeatureState),
);
// Reminder state follows entity adapter pattern
return (reminderState as { entities?: Record<string, unknown> })?.entities?.[
entityId
];
}
// Fallback for unhandled entity types
case 'WORK_CONTEXT':
case 'PLUGIN_USER_DATA':
case 'PLUGIN_METADATA':
case 'MIGRATION':
case 'RECOVERY':
case 'ALL':
default:
OpLog.warn(
`ConflictResolutionService: No selector for entity type ${entityType}, falling back to remote`,
);
return undefined;
// Standard props-based selector
return await firstValueFrom(
this.store.select(config.selectById as any, { id: entityId }),
);
}
// Singleton entities - return entire feature state
if (isSingletonEntity(config) && config.selectState) {
return await firstValueFrom(this.store.select(config.selectState));
}
// Map entities - get state and extract by key
if (isMapEntity(config) && config.selectState && config.mapKey) {
const state = await firstValueFrom(this.store.select(config.selectState));
return (state as Record<string, unknown>)?.[config.mapKey]?.[entityId];
}
// Array entities - get state and find by id
if (isArrayEntity(config) && config.selectState) {
const state = await firstValueFrom(this.store.select(config.selectState));
if (config.arrayKey === null) {
// State IS the array (e.g., REMINDER)
return (state as Array<{ id: string }>)?.find((item) => item.id === entityId);
}
// State has array at arrayKey (e.g., BOARD.boardCfgs)
if (config.arrayKey) {
const arr = (state as Record<string, unknown>)?.[config.arrayKey];
return (arr as Array<{ id: string }>)?.find((item) => item.id === entityId);
}
return undefined;
}
OpLog.warn(
`ConflictResolutionService: Cannot get state for entity type ${entityType}`,
);
return undefined;
} catch (err) {
OpLog.err(
`ConflictResolutionService: Error getting entity state for ${entityType}:${entityId}`,

View file

@ -1,51 +1,9 @@
import { Injectable, inject } from '@angular/core';
import { Operation, EntityType, extractActionPayload } from '../operation.types';
import { Store } from '@ngrx/store';
import { selectTaskEntities } from '../../../../features/tasks/store/task.selectors';
import {
selectProjectFeatureState,
selectEntities as selectProjectEntitiesFromAdapter,
} from '../../../../features/project/store/project.reducer';
import {
selectTagFeatureState,
selectEntities as selectTagEntitiesFromAdapter,
} from '../../../../features/tag/store/tag.reducer';
import {
selectNoteFeatureState,
selectEntities as selectNoteEntitiesFromAdapter,
} from '../../../../features/note/store/note.reducer';
import {
selectMetricFeatureState,
selectEntities as selectMetricEntitiesFromAdapter,
} from '../../../../features/metric/store/metric.selectors';
import {
selectSimpleCounterFeatureState,
selectEntities as selectSimpleCounterEntitiesFromAdapter,
} from '../../../../features/simple-counter/store/simple-counter.reducer';
import { firstValueFrom } from 'rxjs';
import { createSelector, MemoizedSelector } from '@ngrx/store';
import { Dictionary } from '@ngrx/entity';
/**
* Registry of entity dictionary selectors for bulk lookups.
* To add a new entity type, simply add its selector here.
* Entity types not in this registry are assumed to be singleton/aggregate types
* that always exist (e.g., GLOBAL_CONFIG, PLANNER).
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyDictionarySelector = MemoizedSelector<object, Dictionary<any>>;
const ENTITY_SELECTORS: Partial<Record<EntityType, AnyDictionarySelector>> = {
TASK: selectTaskEntities,
PROJECT: createSelector(selectProjectFeatureState, selectProjectEntitiesFromAdapter),
TAG: createSelector(selectTagFeatureState, selectTagEntitiesFromAdapter),
NOTE: createSelector(selectNoteFeatureState, selectNoteEntitiesFromAdapter),
METRIC: createSelector(selectMetricFeatureState, selectMetricEntitiesFromAdapter),
SIMPLE_COUNTER: createSelector(
selectSimpleCounterFeatureState,
selectSimpleCounterEntitiesFromAdapter,
),
};
import { getEntityConfig, isAdapterEntity, EntityDependency } from '../entity-registry';
export interface OperationDependency {
entityType: EntityType;
@ -54,114 +12,34 @@ export interface OperationDependency {
relation: 'parent' | 'reference';
}
/** Payload shape for task operations that may have dependencies */
interface TaskOperationPayload {
projectId?: string;
parentId?: string;
tagIds?: string[];
subTaskIds?: string[];
}
/** Payload shape for note operations that may have dependencies */
interface NoteOperationPayload {
projectId?: string;
}
/** Payload shape for tag operations that may have dependencies */
interface TagOperationPayload {
taskIds?: string[];
// For updateTag action, taskIds are nested in tag.changes
tag?: {
id?: string;
changes?: {
taskIds?: string[];
};
};
}
@Injectable({ providedIn: 'root' })
export class DependencyResolverService {
private store = inject(Store);
/**
* Identifies dependencies for a given operation.
* Identifies dependencies for a given operation using the entity registry.
*/
extractDependencies(op: Operation): OperationDependency[] {
const deps: OperationDependency[] = [];
const config = getEntityConfig(op.entityType);
if (!config || !config.dependencies?.length) {
return [];
}
// Extract actionPayload for both multi-entity and legacy payloads
const deps: OperationDependency[] = [];
const actionPayload = extractActionPayload(op.payload);
if (op.entityType === 'TASK') {
const payload = actionPayload as TaskOperationPayload;
if (payload.projectId) {
for (const depConfig of config.dependencies) {
const ids = this.extractIdsFromPayload(actionPayload, depConfig, op.entityType);
for (const id of ids) {
deps.push({
entityType: 'PROJECT',
entityId: payload.projectId,
mustExist: false, // Soft dependency (Task can exist without project, or orphaned)
relation: 'reference',
});
}
if (payload.parentId) {
deps.push({
entityType: 'TASK',
entityId: payload.parentId,
mustExist: true, // Hard dependency (Subtask needs parent)
relation: 'parent',
});
}
// SubTaskIds are soft dependencies - parent task references children
// We want subtasks to be created before parent updates that reference them
if (payload.subTaskIds?.length) {
for (const subTaskId of payload.subTaskIds) {
deps.push({
entityType: 'TASK',
entityId: subTaskId,
mustExist: false, // Soft dependency (prefer subtask exists, but don't block)
relation: 'reference',
});
}
}
// Tags are soft dependencies, we usually ignore if missing or handle in reducer
}
if (op.entityType === 'NOTE') {
const notePayload = actionPayload as NoteOperationPayload;
if (notePayload.projectId) {
deps.push({
entityType: 'PROJECT',
entityId: notePayload.projectId,
mustExist: false, // Soft dependency (Note can exist without project)
relation: 'reference',
entityType: depConfig.dependsOn,
entityId: id,
mustExist: depConfig.isHard,
relation: depConfig.relation,
});
}
}
if (op.entityType === 'TAG') {
const tagPayload = actionPayload as TagOperationPayload;
// Tag -> Task is a soft dependency. We want tasks to be created before
// tag updates that reference them, to avoid the tag-shared.reducer
// filtering out "non-existent" taskIds during sync.
// Also ensures DELETE operations for tasks wait until after this tag update.
// Handle both direct taskIds (addTag) and nested taskIds (updateTag)
const taskIds = tagPayload.taskIds || tagPayload.tag?.changes?.taskIds;
if (taskIds?.length) {
for (const taskId of taskIds) {
deps.push({
entityType: 'TASK',
entityId: taskId,
mustExist: false, // Soft dependency (prefer task exists, but don't block)
relation: 'reference',
});
}
}
}
// SIMPLE_COUNTER, METRIC - typically don't have hard dependencies
// PROJECT - typically independent unless nested structure is used
return deps;
}
@ -242,6 +120,37 @@ export class DependencyResolverService {
return missing.length > 0;
}
/**
* Extracts ID(s) from payload based on dependency config.
*/
private extractIdsFromPayload(
payload: Record<string, unknown>,
depConfig: EntityDependency,
entityType: EntityType,
): string[] {
const { payloadField } = depConfig;
// Direct field access
let value = payload[payloadField];
// Handle nested paths for TAG entity (e.g., tag.changes.taskIds)
if (!value && entityType === 'TAG' && payloadField === 'taskIds') {
const tag = payload['tag'] as { changes?: { taskIds?: string[] } } | undefined;
value = tag?.changes?.taskIds;
}
if (!value) return [];
// Return as array
if (Array.isArray(value)) {
return value.filter((v): v is string => typeof v === 'string');
}
if (typeof value === 'string') {
return [value];
}
return [];
}
/**
* Fetches entity dictionaries for the requested entity types.
* Only makes one selector call per entity type, regardless of how many entities are checked.
@ -253,15 +162,15 @@ export class DependencyResolverService {
const promises: Promise<void>[] = [];
for (const entityType of depsByType.keys()) {
const selector = ENTITY_SELECTORS[entityType];
if (selector) {
const config = getEntityConfig(entityType);
if (config && isAdapterEntity(config) && config.selectEntities) {
promises.push(
firstValueFrom(this.store.select(selector)).then((dict) => {
firstValueFrom(this.store.select(config.selectEntities)).then((dict) => {
result.set(entityType, dict);
}),
);
}
// Entity types not in ENTITY_SELECTORS are singleton/aggregate - assumed to exist
// Entity types not adapters are singleton/aggregate - assumed to exist
}
await Promise.all(promises);

View file

@ -314,7 +314,7 @@ describe('lwwUpdateMetaReducer', () => {
reducer(state, action);
expect(console.warn).toHaveBeenCalledWith(
jasmine.stringMatching(/Unknown entity type: UNKNOWN_ENTITY/),
jasmine.stringMatching(/Unknown or non-adapter entity type: UNKNOWN_ENTITY/),
);
expect(mockReducer).toHaveBeenCalledWith(state, action);
});

View file

@ -1,28 +1,11 @@
import { Action, ActionReducer, MetaReducer } from '@ngrx/store';
import { EntityAdapter } from '@ngrx/entity';
import { RootState } from '../../root-state';
import {
PROJECT_FEATURE_NAME,
projectAdapter,
} from '../../../features/project/store/project.reducer';
import { TAG_FEATURE_NAME, tagAdapter } from '../../../features/tag/store/tag.reducer';
import {
TASK_FEATURE_NAME,
taskAdapter,
} from '../../../features/tasks/store/task.reducer';
import {
adapter as simpleCounterAdapter,
SIMPLE_COUNTER_FEATURE_NAME,
} from '../../../features/simple-counter/store/simple-counter.reducer';
import {
adapter as taskRepeatCfgAdapter,
TASK_REPEAT_CFG_FEATURE_NAME,
} from '../../../features/task-repeat-cfg/store/task-repeat-cfg.selectors';
import {
adapter as noteAdapter,
NOTE_FEATURE_NAME,
} from '../../../features/note/store/note.reducer';
import { EntityType } from '../../../core/persistence/operation-log/operation.types';
import {
getEntityConfig,
isAdapterEntity,
} from '../../../core/persistence/operation-log/entity-registry';
/**
* Regex to match LWW Update action types.
@ -30,28 +13,6 @@ import { EntityType } from '../../../core/persistence/operation-log/operation.ty
*/
const LWW_UPDATE_REGEX = /^\[([A-Z_]+)\] LWW Update$/;
/**
* Map from entity type to feature name and adapter.
* This allows us to handle LWW Update for all entity types generically.
*/
const ENTITY_CONFIG: Record<
string,
{ featureName: string; adapter: EntityAdapter<any> }
> = {
TASK: { featureName: TASK_FEATURE_NAME, adapter: taskAdapter },
PROJECT: { featureName: PROJECT_FEATURE_NAME, adapter: projectAdapter },
TAG: { featureName: TAG_FEATURE_NAME, adapter: tagAdapter },
NOTE: { featureName: NOTE_FEATURE_NAME, adapter: noteAdapter },
SIMPLE_COUNTER: {
featureName: SIMPLE_COUNTER_FEATURE_NAME,
adapter: simpleCounterAdapter,
},
TASK_REPEAT_CFG: {
featureName: TASK_REPEAT_CFG_FEATURE_NAME,
adapter: taskRepeatCfgAdapter,
},
};
/**
* Meta-reducer that handles LWW (Last-Write-Wins) Update actions.
*
@ -86,15 +47,24 @@ export const lwwUpdateMetaReducer: MetaReducer = (
}
const entityType = match[1] as EntityType;
const config = ENTITY_CONFIG[entityType];
const config = getEntityConfig(entityType);
if (!config) {
console.warn(`lwwUpdateMetaReducer: Unknown entity type: ${entityType}`);
if (!config || !isAdapterEntity(config)) {
console.warn(
`lwwUpdateMetaReducer: Unknown or non-adapter entity type: ${entityType}`,
);
return reducer(state, action);
}
const { featureName, adapter } = config;
if (!featureName || !adapter) {
console.warn(
`lwwUpdateMetaReducer: Missing featureName or adapter for: ${entityType}`,
);
return reducer(state, action);
}
const rootState = state as RootState;
const { featureName, adapter } = config;
const featureState = rootState[featureName as keyof RootState];
if (!featureState) {
@ -128,7 +98,7 @@ export const lwwUpdateMetaReducer: MetaReducer = (
console.log(
`lwwUpdateMetaReducer: Entity ${entityType}:${entityId} not found, recreating from LWW update`,
);
updatedFeatureState = adapter.addOne(
updatedFeatureState = (adapter as EntityAdapter<any>).addOne(
{
...entityData,
modified: Date.now(),
@ -138,7 +108,7 @@ export const lwwUpdateMetaReducer: MetaReducer = (
} else {
// Entity exists - replace it entirely with the LWW winning state
// Use updateOne with all fields as changes to preserve adapter behavior
updatedFeatureState = adapter.updateOne(
updatedFeatureState = (adapter as EntityAdapter<any>).updateOne(
{
id: entityId,
changes: {