mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
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:
parent
3caad75980
commit
58372626f1
9 changed files with 779 additions and 305 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
|
|
|
|||
224
eslint-local-rules/rules/require-entity-registry.js
Normal file
224
eslint-local-rules/rules/require-entity-registry.js
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
420
src/app/core/persistence/operation-log/entity-registry.ts
Normal file
420
src/app/core/persistence/operation-log/entity-registry.ts
Normal 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);
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue