diff --git a/__tests__/unit/views/resolvers/custom-resolvers.test.ts b/__tests__/unit/views/resolvers/custom-resolvers.test.ts index 0733e79..3033e00 100644 --- a/__tests__/unit/views/resolvers/custom-resolvers.test.ts +++ b/__tests__/unit/views/resolvers/custom-resolvers.test.ts @@ -1,18 +1,17 @@ -import { - RhizomeNode, - Lossless, - CustomResolver, - ResolverPlugin, - LastWriteWinsPlugin, - FirstWriteWinsPlugin, - ConcatenationPlugin, - MajorityVotePlugin, - MinPlugin, - MaxPlugin, - PropertyTypes, - CollapsedDelta, - createDelta -} from "../../../../src"; +import { RhizomeNode, Lossless, createDelta } from "../../../../src"; +import { CollapsedDelta } from "../../../../src/views/lossless"; +import { + CustomResolver, + ResolverPlugin, + LastWriteWinsPlugin, + FirstWriteWinsPlugin, + ConcatenationPlugin, + MajorityVotePlugin, + MinPlugin, + MaxPlugin +} from "../../../../src/views/resolvers/custom-resolvers"; + +type PropertyTypes = string | number | boolean | null; describe('Custom Resolvers', () => { let node: RhizomeNode; diff --git a/plans/housekeeping.md b/plans/housekeeping.md index 99daecf..37d3899 100644 --- a/plans/housekeeping.md +++ b/plans/housekeeping.md @@ -5,3 +5,4 @@ - [ ] Rename/consolidate, lossless view() and compose() --> composeView() - [ ] Rename Lossless to HyperView - [ ] Rename Lossy to View +- [ ] Consider whether we should use collapsed deltas diff --git a/src/views/resolvers/custom-resolvers/index.ts b/src/views/resolvers/custom-resolvers/index.ts new file mode 100644 index 0000000..8772c2d --- /dev/null +++ b/src/views/resolvers/custom-resolvers/index.ts @@ -0,0 +1,3 @@ +export * from './plugin'; +export * from './resolver'; +export * from './plugins'; diff --git a/src/views/resolvers/custom-resolvers/plugin.ts b/src/views/resolvers/custom-resolvers/plugin.ts new file mode 100644 index 0000000..ab73a0b --- /dev/null +++ b/src/views/resolvers/custom-resolvers/plugin.ts @@ -0,0 +1,45 @@ +import { PropertyID, PropertyTypes } from "../../../core/types"; +import { CollapsedDelta } from "../../lossless"; + +/** + * Plugin interface for custom resolvers + */ +export interface ResolverPlugin { + name: string; + + /** + * Array of property IDs that this plugin depends on. + * These properties will be processed before this plugin. + */ + dependencies?: PropertyID[]; + + /** + * Initialize the state for a property + */ + initialize(): T; + + /** + * Process a new value for the property + */ + update( + currentState: T, + newValue: PropertyTypes, + delta: CollapsedDelta, + allStates?: Record + ): T; + + /** + * Resolve the final value from the accumulated state + */ + resolve( + state: T, + allStates?: Record + ): PropertyTypes | undefined; +} + +/** + * Configuration for custom resolver + */ +export type CustomResolverConfig = { + [propertyId: PropertyID]: ResolverPlugin; +}; diff --git a/src/views/resolvers/custom-resolvers/plugins/concatenation.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/concatenation.plugin.ts new file mode 100644 index 0000000..dd464e6 --- /dev/null +++ b/src/views/resolvers/custom-resolvers/plugins/concatenation.plugin.ts @@ -0,0 +1,57 @@ +import { PropertyTypes } from "../../../../core/types"; +import { CollapsedDelta } from "../../../lossless"; +import { ResolverPlugin } from "../plugin"; + +/** + * Concatenation plugin (for string values) + * + * Concatenates all string values with a separator + */ +export class ConcatenationPlugin implements ResolverPlugin<{ values: { value: string, timestamp: number }[] }> { + name = 'concatenation'; + dependencies: string[] = []; + + constructor(private separator: string = ' ') {} + + initialize() { + return { values: [] }; + } + + update( + currentState: { values: { value: string, timestamp: number }[] }, + newValue: PropertyTypes, + delta: CollapsedDelta, + _allStates?: Record + ) { + if (typeof newValue === 'string') { + return { + values: [ + ...currentState.values, + { value: newValue, timestamp: delta.timeCreated } + ] + }; + } + return currentState; + } + + resolve( + state: { values: { value: string, timestamp: number }[] }, + _allStates?: Record + ): PropertyTypes { + // Sort by timestamp to ensure consistent ordering + const sortedValues = [...state.values].sort((a, b) => a.timestamp - b.timestamp); + + // Use a Set to track seen values and keep only the first occurrence of each value + const seen = new Set(); + const uniqueValues: string[] = []; + + for (const { value } of sortedValues) { + if (!seen.has(value)) { + seen.add(value); + uniqueValues.push(value); + } + } + + return uniqueValues.join(this.separator); + } +} diff --git a/src/views/resolvers/custom-resolvers/plugins/first-write-wins.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/first-write-wins.plugin.ts new file mode 100644 index 0000000..c7d8d1b --- /dev/null +++ b/src/views/resolvers/custom-resolvers/plugins/first-write-wins.plugin.ts @@ -0,0 +1,40 @@ +import { PropertyTypes } from "../../../../core/types"; +import { CollapsedDelta } from "../../../lossless"; +import { ResolverPlugin } from "../plugin"; + +/** + * First Write Wins plugin + * + * Keeps the first value that was written, ignoring subsequent writes + */ +export class FirstWriteWinsPlugin implements ResolverPlugin<{ value?: PropertyTypes, timestamp: number }> { + name = 'first-write-wins'; + dependencies: string[] = []; + + initialize() { + return { timestamp: Infinity }; + } + + update( + currentState: { value?: PropertyTypes, timestamp: number }, + newValue: PropertyTypes, + delta: CollapsedDelta, + _allStates?: Record + ) { + // Only update if this delta is earlier than our current earliest + if (delta.timeCreated < currentState.timestamp) { + return { + value: newValue, + timestamp: delta.timeCreated + }; + } + return currentState; + } + + resolve( + state: { value?: PropertyTypes, timestamp: number }, + _allStates?: Record + ): PropertyTypes | undefined { + return state.value; + } +} diff --git a/src/views/resolvers/custom-resolvers/plugins/index.ts b/src/views/resolvers/custom-resolvers/plugins/index.ts new file mode 100644 index 0000000..87b914d --- /dev/null +++ b/src/views/resolvers/custom-resolvers/plugins/index.ts @@ -0,0 +1,6 @@ +export * from './last-write-wins.plugin'; +export * from './first-write-wins.plugin'; +export * from './concatenation.plugin'; +export * from './majority-vote.plugin'; +export * from './min.plugin'; +export * from './max.plugin'; diff --git a/src/views/resolvers/custom-resolvers/plugins/last-write-wins.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/last-write-wins.plugin.ts new file mode 100644 index 0000000..27d829e --- /dev/null +++ b/src/views/resolvers/custom-resolvers/plugins/last-write-wins.plugin.ts @@ -0,0 +1,39 @@ +import { PropertyID, PropertyTypes } from "../../../../core/types"; +import { CollapsedDelta } from "../../../lossless"; +import { ResolverPlugin } from "../plugin"; + +/** + * Last Write Wins plugin + * + * Keeps the most recent value based on the delta's timestamp + */ +export class LastWriteWinsPlugin implements ResolverPlugin<{ value?: PropertyTypes, timestamp: number }> { + name = 'last-write-wins'; + dependencies: PropertyID[] = []; + + initialize() { + return { timestamp: 0 }; + } + + update( + currentState: { value?: PropertyTypes, timestamp: number }, + newValue: PropertyTypes, + delta: CollapsedDelta, + _allStates?: Record + ) { + if (delta.timeCreated > currentState.timestamp) { + return { + value: newValue, + timestamp: delta.timeCreated + }; + } + return currentState; + } + + resolve( + state: { value?: PropertyTypes, timestamp: number }, + _allStates?: Record + ): PropertyTypes { + return state.value || ''; + } +} diff --git a/src/views/resolvers/custom-resolvers/plugins/majority-vote.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/majority-vote.plugin.ts new file mode 100644 index 0000000..603c294 --- /dev/null +++ b/src/views/resolvers/custom-resolvers/plugins/majority-vote.plugin.ts @@ -0,0 +1,45 @@ +import { PropertyTypes } from "../../../../core/types"; +import { CollapsedDelta } from "../../../lossless"; +import { ResolverPlugin } from "../plugin"; + +/** + * Majority vote plugin + * + * Returns the value that appears most frequently + */ +export class MajorityVotePlugin implements ResolverPlugin<{ votes: Map }> { + name = 'majority-vote'; + dependencies: string[] = []; + + initialize() { + return { votes: new Map() }; + } + + update( + currentState: { votes: Map }, + newValue: PropertyTypes, + _delta: CollapsedDelta, + _allStates?: Record + ) { + const currentCount = currentState.votes.get(newValue) || 0; + currentState.votes.set(newValue, currentCount + 1); + return currentState; + } + + resolve( + state: { votes: Map }, + _allStates?: Record + ): PropertyTypes | undefined { + let maxCount = 0; + let result: PropertyTypes | undefined; + + state.votes.forEach((count, value) => { + if (count > maxCount) { + maxCount = count; + result = value; + } + }); + + return result; + } +} diff --git a/src/views/resolvers/custom-resolvers/plugins/max.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/max.plugin.ts new file mode 100644 index 0000000..c5e8067 --- /dev/null +++ b/src/views/resolvers/custom-resolvers/plugins/max.plugin.ts @@ -0,0 +1,38 @@ +import { PropertyTypes } from "../../../../core/types"; +import { CollapsedDelta } from "../../../lossless"; +import { ResolverPlugin } from "../plugin"; + +/** + * Numeric max plugin + * + * Tracks the maximum numeric value + */ +export class MaxPlugin implements ResolverPlugin<{ max?: number }> { + name = 'max'; + dependencies: string[] = []; + + initialize() { + return { max: undefined }; + } + + update( + currentState: { max?: number }, + newValue: PropertyTypes, + _delta: CollapsedDelta, + _allStates?: Record + ) { + const numValue = typeof newValue === 'number' ? newValue : parseFloat(String(newValue)); + + if (!isNaN(numValue) && (currentState.max === undefined || numValue > currentState.max)) { + return { max: numValue }; + } + return currentState; + } + + resolve( + state: { max?: number }, + _allStates?: Record + ): PropertyTypes | undefined { + return state.max; + } +} diff --git a/src/views/resolvers/custom-resolvers/plugins/min.plugin.ts b/src/views/resolvers/custom-resolvers/plugins/min.plugin.ts new file mode 100644 index 0000000..e3efe08 --- /dev/null +++ b/src/views/resolvers/custom-resolvers/plugins/min.plugin.ts @@ -0,0 +1,38 @@ +import { PropertyTypes } from "../../../../core/types"; +import { CollapsedDelta } from "../../../lossless"; +import { ResolverPlugin } from "../plugin"; + +/** + * Numeric min plugin + * + * Tracks the minimum numeric value + */ +export class MinPlugin implements ResolverPlugin<{ min?: number }> { + name = 'min'; + dependencies: string[] = []; + + initialize() { + return { min: undefined }; + } + + update( + currentState: { min?: number }, + newValue: PropertyTypes, + _delta: CollapsedDelta, + _allStates?: Record + ) { + const numValue = typeof newValue === 'number' ? newValue : parseFloat(String(newValue)); + + if (!isNaN(numValue) && (currentState.min === undefined || numValue < currentState.min)) { + return { min: numValue }; + } + return currentState; + } + + resolve( + state: { min?: number }, + _allStates?: Record + ): PropertyTypes | undefined { + return state.min; + } +} diff --git a/src/views/resolvers/custom-resolvers.ts b/src/views/resolvers/custom-resolvers/resolver.ts similarity index 51% rename from src/views/resolvers/custom-resolvers.ts rename to src/views/resolvers/custom-resolvers/resolver.ts index 503a69b..80dfd9e 100644 --- a/src/views/resolvers/custom-resolvers.ts +++ b/src/views/resolvers/custom-resolvers/resolver.ts @@ -1,40 +1,8 @@ -import { EntityProperties } from "../../core/entity"; -import { CollapsedDelta, Lossless, LosslessViewOne } from "../lossless"; -import { Lossy } from '../lossy'; -import { DomainEntityID, PropertyID, PropertyTypes, ViewMany } from "../../core/types"; - -// Plugin interface for custom resolvers -export interface ResolverPlugin { - name: string; - - /** - * Array of property IDs that this plugin depends on. - * These properties will be processed before this plugin. - */ - dependencies?: PropertyID[]; - - // Initialize the state for a property - initialize(): T; - - // Process a new value for the property - update( - currentState: T, - newValue: PropertyTypes, - delta: CollapsedDelta, - allStates?: Record - ): T; - - // Resolve the final value from the accumulated state - resolve( - state: T, - allStates?: Record - ): PropertyTypes | undefined; -} - -// Configuration for custom resolver -export type CustomResolverConfig = { - [propertyId: PropertyID]: ResolverPlugin; -}; +import { EntityProperties } from "../../../core/entity"; +import { CollapsedDelta, Lossless, LosslessViewOne } from "../../lossless"; +import { Lossy } from '../../lossy'; +import { DomainEntityID, PropertyID, PropertyTypes, ViewMany } from "../../../core/types"; +import { ResolverPlugin } from "./plugin"; type PropertyState = { plugin: ResolverPlugin; @@ -57,7 +25,9 @@ type CustomResolverResult = ViewMany<{ properties: EntityProperties; }>; -// Extract value from delta for a specific property +/** + * Extract value from delta for a specific property + */ function extractValueFromDelta(propertyId: PropertyID, delta: CollapsedDelta): PropertyTypes | undefined { for (const pointer of delta.pointers) { for (const [key, value] of Object.entries(pointer)) { @@ -71,11 +41,11 @@ function extractValueFromDelta(propertyId: PropertyID, delta: CollapsedDelta): P export class CustomResolver extends Lossy { private executionOrder: PropertyID[]; - private readonly config: CustomResolverConfig; + private readonly config: Record; constructor( lossless: Lossless, - config: CustomResolverConfig + config: Record ) { super(lossless); this.config = config; @@ -258,212 +228,4 @@ export class CustomResolver extends Lossy { - name = 'last-write-wins'; - dependencies: PropertyID[] = []; - - initialize() { - return { timestamp: 0 }; - } - - update( - currentState: { value?: PropertyTypes, timestamp: number }, - newValue: PropertyTypes, - delta: CollapsedDelta, - _allStates?: Record - ) { - if (delta.timeCreated > currentState.timestamp) { - return { - value: newValue, - timestamp: delta.timeCreated - }; - } - return currentState; - } - - resolve( - state: { value?: PropertyTypes, timestamp: number }, - _allStates?: Record - ): PropertyTypes { - return state.value || ''; - } -} - -// First Write Wins plugin -export class FirstWriteWinsPlugin implements ResolverPlugin<{ value?: PropertyTypes, timestamp: number }> { - name = 'first-write-wins'; - dependencies: PropertyID[] = []; - - initialize() { - return { timestamp: Infinity }; - } - - update( - currentState: { value?: PropertyTypes, timestamp: number }, - newValue: PropertyTypes, - delta: CollapsedDelta, - _allStates?: Record - ) { - if (delta.timeCreated < currentState.timestamp) { - return { - value: newValue, - timestamp: delta.timeCreated - }; - } - return currentState; - } - - resolve( - state: { value?: PropertyTypes, timestamp: number }, - _allStates?: Record - ): PropertyTypes { - return state.value || ''; - } -} - -// Concatenation plugin (for string values) -export class ConcatenationPlugin implements ResolverPlugin<{ values: { value: string, timestamp: number }[] }> { - name = 'concatenation'; - dependencies: PropertyID[] = []; - - constructor(private separator: string = ' ') { } - - initialize() { - return { values: [] }; - } - - update( - currentState: { values: { value: string, timestamp: number }[] }, - newValue: PropertyTypes, - delta: CollapsedDelta, - _allStates?: Record - ) { - if (typeof newValue === 'string') { - // Check if this value already exists (avoid duplicates) - const exists = currentState.values.some(v => v.value === newValue); - if (!exists) { - currentState.values.push({ - value: newValue, - timestamp: delta.timeCreated - }); - // Sort by timestamp to maintain chronological order - currentState.values.sort((a, b) => a.timestamp - b.timestamp); - } - } - return currentState; - } - - resolve( - state: { values: { value: string, timestamp: number }[] }, - _allStates?: Record - ): PropertyTypes { - return state.values.map(v => v.value).join(this.separator); - } -} - -// Majority vote plugin -export class MajorityVotePlugin implements ResolverPlugin<{ votes: Map }> { - name = 'majority-vote'; - dependencies: PropertyID[] = []; - - initialize() { - return { votes: new Map() }; - } - - update( - currentState: { votes: Map }, - newValue: PropertyTypes, - _delta: CollapsedDelta, - _allStates?: Record - ) { - const currentCount = currentState.votes.get(newValue) || 0; - currentState.votes.set(newValue, currentCount + 1); - return currentState; - } - - resolve( - state: { votes: Map }, - _allStates?: Record - ): PropertyTypes { - let maxVotes = 0; - let winner: PropertyTypes = ''; - - for (const [value, votes] of state.votes.entries()) { - if (votes > maxVotes) { - maxVotes = votes; - winner = value; - } - } - - return winner; - } -} - -// Numeric min plugin -export class MinPlugin implements ResolverPlugin<{ min?: number }> { - name = 'min'; - dependencies: PropertyID[] = []; - - initialize() { - return {}; - } - - update( - currentState: { min?: number }, - newValue: PropertyTypes, - _delta: CollapsedDelta, - _allStates?: Record - ) { - if (typeof newValue === 'number') { - if (currentState.min === undefined || newValue < currentState.min) { - return { min: newValue }; - } - } - return currentState; - } - - resolve( - state: { min?: number }, - _allStates?: Record - ): PropertyTypes | undefined { - return state.min; - } -} - -// Numeric max plugin -export class MaxPlugin implements ResolverPlugin<{ max?: number }> { - name = 'max'; - dependencies: PropertyID[] = []; - - initialize() { - return {}; - } - - update( - currentState: { max?: number }, - newValue: PropertyTypes, - _delta: CollapsedDelta, - _allStates?: Record - ) { - if (typeof newValue === 'number') { - if (currentState.max === undefined || newValue > currentState.max) { - return { max: newValue }; - } - } - return currentState; - } - - resolve( - state: { max?: number }, - _allStates?: Record - ): PropertyTypes | undefined { - return state.max; - } -} \ No newline at end of file