diff --git a/src/models/clone-plain.ts b/src/models/clone-plain.ts new file mode 100644 index 0000000..4346e63 --- /dev/null +++ b/src/models/clone-plain.ts @@ -0,0 +1,27 @@ +import { isProxy, toRaw } from 'vue'; + +function normalizeCloneable(value: T): T { + if (value === null || value === undefined) { + return value; + } + + if (typeof value !== 'object') { + return value; + } + + const rawValue = isProxy(value) ? toRaw(value) : value; + + if (Array.isArray(rawValue)) { + return rawValue.map(item => normalizeCloneable(item)) as T; + } + + const plainObject = Object.fromEntries( + Object.entries(rawValue).map(([key, nestedValue]) => [key, normalizeCloneable(nestedValue)]) + ); + + return plainObject as T; +} + +export function clonePlain(value: T): T { + return structuredClone(normalizeCloneable(value)); +} diff --git a/src/models/collection.ts b/src/models/collection.ts index ef9b089..c56e85c 100644 --- a/src/models/collection.ts +++ b/src/models/collection.ts @@ -1,47 +1,52 @@ /** - * Class model for Collection Interface + * Class models for Collection interfaces */ -import type { CollectionContentTypes, CollectionInterface, CollectionPropertiesInterface } from "@/types/collection"; +import type { + CollectionContentTypes, + CollectionInterface, + CollectionModelInterface, + CollectionPropertiesInterface, + CollectionPropertiesModelInterface +} from '@/types/collection'; +import type { + CollectionIdentifier, + ServiceIdentifier +} from '@/types/common'; +import { clonePlain } from './clone-plain'; -export class CollectionObject implements CollectionInterface { +export class CollectionObject implements CollectionModelInterface { - _data!: CollectionInterface; + private _data: CollectionInterface; + private _properties: CollectionPropertiesObject | undefined = undefined; constructor() { this._data = { + '@type': 'people:collection', + version: 1, provider: '', - service: '', - collection: null, - identifier: '', - signature: null, - created: null, - modified: null, - properties: new CollectionPropertiesObject(), + service: '' as ServiceIdentifier, + collection: null as CollectionIdentifier | null, + identifier: '' as CollectionIdentifier, + properties: new CollectionPropertiesObject().toJson(), }; } fromJson(data: CollectionInterface): CollectionObject { - this._data = data; - if (data.properties) { - this._data.properties = new CollectionPropertiesObject().fromJson(data.properties as CollectionPropertiesInterface); - } + this._data = clonePlain(data); + this._properties = undefined; return this; } toJson(): CollectionInterface { - const json = { ...this._data }; - if (this._data.properties instanceof CollectionPropertiesObject) { - json.properties = this._data.properties.toJson(); - } - return json; + const json = this._properties + ? { ...this._data, properties: this._properties.toJson() } + : this._data; + return clonePlain(json); } clone(): CollectionObject { - const cloned = new CollectionObject(); - cloned._data = { ...this._data }; - cloned._data.properties = this.properties.clone(); - return cloned; + return new CollectionObject().fromJson(this.toJson()); } /** Immutable Properties */ @@ -50,16 +55,16 @@ export class CollectionObject implements CollectionInterface { return this._data.provider; } - get service(): string | number { - return this._data.service; + get service(): ServiceIdentifier { + return this._data.service as ServiceIdentifier; } - get collection(): string | number | null { - return this._data.collection; + get collection(): CollectionIdentifier | null { + return this._data.collection as CollectionIdentifier | null; } - get identifier(): string | number { - return this._data.identifier; + get identifier(): CollectionIdentifier { + return this._data.identifier as CollectionIdentifier; } get signature(): string | null | undefined { @@ -74,37 +79,33 @@ export class CollectionObject implements CollectionInterface { return this._data.modified; } + /** Mutable Properties */ + get properties(): CollectionPropertiesObject { - if (this._data.properties instanceof CollectionPropertiesObject) { - return this._data.properties; + if (this._properties) { + return this._properties; + } + else if (this._data.properties) { + const properties = new CollectionPropertiesObject().fromJson(this._data.properties as CollectionPropertiesInterface); + this._properties = properties; + return properties; } - if (this._data.properties) { - const hydrated = new CollectionPropertiesObject().fromJson(this._data.properties as CollectionPropertiesInterface); - this._data.properties = hydrated; - return hydrated; - } - return new CollectionPropertiesObject(); } set properties(value: CollectionPropertiesObject) { - if (value instanceof CollectionPropertiesObject) { - this._data.properties = value as any; - } else { - this._data.properties = value; - } + this._properties = value; } } -export class CollectionPropertiesObject implements CollectionPropertiesInterface { - - _data!: CollectionPropertiesInterface; +export class CollectionPropertiesObject implements CollectionPropertiesModelInterface { + + private _data: CollectionPropertiesInterface; constructor() { this._data = { - '@type': 'chrono:collection', - version: 1, + '@type': 'chrono:calendar', content: [], label: '', description: null, @@ -115,32 +116,22 @@ export class CollectionPropertiesObject implements CollectionPropertiesInterface } fromJson(data: CollectionPropertiesInterface): CollectionPropertiesObject { - this._data = data; + this._data = clonePlain(data); return this; } toJson(): CollectionPropertiesInterface { - return this._data; + return clonePlain(this._data); } clone(): CollectionPropertiesObject { - const cloned = new CollectionPropertiesObject(); - cloned._data = { ...this._data }; - return cloned; + return new CollectionPropertiesObject().fromJson(this.toJson()); } /** Immutable Properties */ - get '@type'(): string { - return this._data['@type']; - } - - get version(): number { - return this._data.version; - } - get content(): CollectionContentTypes[] { - return this._data.content || []; + return this._data.content ?? []; } /** Mutable Properties */ @@ -185,4 +176,4 @@ export class CollectionPropertiesObject implements CollectionPropertiesInterface this._data.color = value; } -} \ No newline at end of file +} diff --git a/src/models/entity.ts b/src/models/entity.ts index 069766a..a8c5d86 100644 --- a/src/models/entity.ts +++ b/src/models/entity.ts @@ -1,106 +1,102 @@ /** * Class model for Entity Interface */ -import type { EntityInterface } from "@/types/entity"; -import type { EventInterface } from "@/types/event"; -import type { TaskInterface } from "@/types/task"; -import type { JournalInterface } from "@/types/journal"; -import { EventObject } from "./event"; -import { TaskObject } from "./task"; -import { JournalObject } from "./journal"; -export class EntityObject implements EntityInterface { +import type { CollectionIdentifier, EntityIdentifier } from '@/types/common'; +import type { EntityInterface, EntityModelInterface, EntityPropertiesInterface } from '@/types/entity'; +import type { EventInterface } from '@/types/event'; +import type { JournalInterface } from '@/types/journal'; +import type { TaskInterface } from '@/types/task'; +import { EventObject } from './event'; +import { JournalObject } from './journal'; +import { TaskObject } from './task'; +import { clonePlain } from './clone-plain'; - _data!: EntityInterface; +export type EntityPropertiesObject = EventObject | TaskObject | JournalObject; + +export class EntityObject implements EntityModelInterface { + private _data: EntityInterface; + private _properties: EntityPropertiesObject | undefined; constructor() { this._data = { + '@type': 'chrono:entity', + version: 1, provider: '', service: '', - collection: '', - identifier: '', + collection: '' as CollectionIdentifier, + identifier: '' as EntityIdentifier, signature: null, created: null, modified: null, - properties: new EventObject(), + properties: new EventObject().toJson(), }; } fromJson(data: EntityInterface): EntityObject { - this._data = data - if (data.properties) { - const type = data.properties.type - if (type === 'task') { - this._data.properties = new TaskObject().fromJson(data.properties as TaskInterface); - } else if (type === 'journal') { - this._data.properties = new JournalObject().fromJson(data.properties as JournalInterface); - } else { - this._data.properties = new EventObject().fromJson(data.properties as EventInterface); - } - } + this._data = clonePlain(data); + this._properties = undefined; return this; } toJson(): EntityInterface { - const json = { ...this._data } - if (this._data.properties instanceof EventObject || - this._data.properties instanceof TaskObject || - this._data.properties instanceof JournalObject) { - json.properties = this._data.properties.toJson(); - } - return json as EntityInterface + const json = this._properties + ? { ...this._data, properties: this._properties.toJson() } + : this._data; + return clonePlain(json); } clone(): EntityObject { - const cloned = new EntityObject() - cloned._data = { ...this._data } - return cloned + return new EntityObject().fromJson(this.toJson()); } - /** Immutable Properties */ + /** Metadata Properties */ get provider(): string { - return this._data.provider + return this._data.provider; } get service(): string { - return this._data.service + return this._data.service; } - get collection(): string | number { - return this._data.collection + get collection(): CollectionIdentifier { + return this._data.collection; } - get identifier(): string | number { - return this._data.identifier + get identifier(): EntityIdentifier { + return this._data.identifier; } get signature(): string | null { - return this._data.signature + return this._data.signature; } get created(): string | null { - return this._data.created + return this._data.created; } get modified(): string | null { - return this._data.modified + return this._data.modified; } - get properties(): EventObject | TaskObject | JournalObject { - if (this._data.properties instanceof EventObject || - this._data.properties instanceof TaskObject || - this._data.properties instanceof JournalObject) { - return this._data.properties + /** Entity Properties (individual | organization | group) */ + + get properties(): EntityPropertiesObject { + if (this._properties) return this._properties; + + const raw = this._data.properties; + if (raw.type === 'task') { + this._properties = new TaskObject().fromJson(raw as TaskInterface); + } else if (raw.type === 'journal') { + this._properties = new JournalObject().fromJson(raw as JournalInterface); + } else { + this._properties = new EventObject().fromJson(raw as EventInterface); } - - const defaultProperties = new EventObject(); - this._data.properties = defaultProperties; - return defaultProperties + return this._properties; } - set properties(value: EventObject | TaskObject | JournalObject) { - this._data.properties = value + set properties(value: EntityPropertiesObject) { + this._properties = value; } - } diff --git a/src/models/identity.ts b/src/models/identity.ts index 3291090..ec9360f 100644 --- a/src/models/identity.ts +++ b/src/models/identity.ts @@ -1,196 +1,124 @@ /** - * Identity implementation classes for Mail Manager services + * Identity models for Chrono Manager services */ import type { - ServiceIdentity, - ServiceIdentityNone, - ServiceIdentityBasic, - ServiceIdentityToken, - ServiceIdentityOAuth, - ServiceIdentityCertificate + ServiceIdentity, + ServiceIdentityBasic, + ServiceIdentityCertificate, + ServiceIdentityNone, + ServiceIdentityOAuth, + ServiceIdentityToken } from '@/types/service'; +import { clonePlain } from './clone-plain'; +import { MutationProxy } from './mutation-proxy'; -/** - * Base Identity class - */ -export abstract class Identity { - abstract toJson(): ServiceIdentity; - - static fromJson(data: ServiceIdentity): Identity { - switch (data.type) { - case 'NA': - return IdentityNone.fromJson(data); - case 'BA': - return IdentityBasic.fromJson(data); - case 'TA': - return IdentityToken.fromJson(data); - case 'OA': - return IdentityOAuth.fromJson(data); - case 'CC': - return IdentityCertificate.fromJson(data); - default: - throw new Error(`Unknown identity type: ${(data as any).type}`); +export abstract class Identity { + protected _original: T; + protected _mutated: Partial; + protected _mutationProxy: MutationProxy; + protected _data: T; + + protected constructor(initial: T) { + this._original = clonePlain(initial); + this._mutated = {}; + this._mutationProxy = new MutationProxy(() => this._original, () => this._mutated); + this._data = this._mutationProxy.create(); + } + + protected load(data: T): this { + this._original = clonePlain(data); + this._mutated = {}; + this._data = this._mutationProxy.create(); + return this; + } + + toJSON(): ServiceIdentity { return this.toJson(); } + toJson(): T; + toJson(delta: true): Partial; + toJson(delta?: boolean): T | Partial { + return delta + ? clonePlain(this._mutated) + : { ...clonePlain(this._original), ...clonePlain(this._mutated) }; + } + + abstract clone(): Identity; + mutated(): boolean { return Reflect.ownKeys(this._mutated).length > 0; } + + static fromJson(data: ServiceIdentity): Identity { + switch (data.type) { + case 'NA': return IdentityNone.fromJson(data); + case 'BA': return IdentityBasic.fromJson(data); + case 'TA': return IdentityToken.fromJson(data); + case 'OA': return IdentityOAuth.fromJson(data); + case 'CC': return IdentityCertificate.fromJson(data); + default: throw new Error(`Unknown identity type: ${(data as any).type}`); + } } - } } -/** - * No authentication - */ -export class IdentityNone extends Identity { - readonly type = 'NA' as const; - - static fromJson(_data: ServiceIdentityNone): IdentityNone { - return new IdentityNone(); - } - - toJson(): ServiceIdentityNone { - return { - type: this.type - }; - } +export class IdentityNone extends Identity { + constructor() { super({ type: 'NA' }); } + static fromJson(_data: ServiceIdentityNone): IdentityNone { return new IdentityNone(); } + clone(): IdentityNone { return IdentityNone.fromJson(this.toJson()); } + get type(): 'NA' { return this._data.type; } } -/** - * Basic authentication (username/password) - */ -export class IdentityBasic extends Identity { - readonly type = 'BA' as const; - identity: string; - secret: string; - - constructor(identity: string = '', secret: string = '') { - super(); - this.identity = identity; - this.secret = secret; - } - - static fromJson(data: ServiceIdentityBasic): IdentityBasic { - return new IdentityBasic(data.identity, data.secret); - } - - toJson(): ServiceIdentityBasic { - return { - type: this.type, - identity: this.identity, - secret: this.secret - }; - } +export class IdentityBasic extends Identity { + constructor(identity: string = '', secret: string = '') { super({ type: 'BA', identity, secret }); } + static fromJson(data: ServiceIdentityBasic): IdentityBasic { return new IdentityBasic().load(data); } + clone(): IdentityBasic { return IdentityBasic.fromJson(this.toJson()); } + get type(): 'BA' { return this._data.type; } + get identity(): string { return this._data.identity; } + set identity(value: string) { this._data.identity = value; } + get secret(): string { return this._data.secret; } + set secret(value: string) { this._data.secret = value; } } -/** - * Token authentication (API key, static token) - */ -export class IdentityToken extends Identity { - readonly type = 'TA' as const; - token: string; - - constructor(token: string = '') { - super(); - this.token = token; - } - - static fromJson(data: ServiceIdentityToken): IdentityToken { - return new IdentityToken(data.token); - } - - toJson(): ServiceIdentityToken { - return { - type: this.type, - token: this.token - }; - } +export class IdentityToken extends Identity { + constructor(token: string = '') { super({ type: 'TA', token }); } + static fromJson(data: ServiceIdentityToken): IdentityToken { return new IdentityToken().load(data); } + clone(): IdentityToken { return IdentityToken.fromJson(this.toJson()); } + get type(): 'TA' { return this._data.type; } + get token(): string { return this._data.token; } + set token(value: string) { this._data.token = value; } } -/** - * OAuth authentication - */ -export class IdentityOAuth extends Identity { - readonly type = 'OA' as const; - accessToken: string; - accessScope?: string[]; - accessExpiry?: number; - refreshToken?: string; - refreshLocation?: string; +export class IdentityOAuth extends Identity { + constructor(accessToken: string = '', accessScope?: string[], accessExpiry?: number, refreshToken?: string, refreshLocation?: string) { + super({ type: 'OA', accessToken, accessScope, accessExpiry, refreshToken, refreshLocation }); + } - constructor( - accessToken: string = '', - accessScope?: string[], - accessExpiry?: number, - refreshToken?: string, - refreshLocation?: string - ) { - super(); - this.accessToken = accessToken; - this.accessScope = accessScope; - this.accessExpiry = accessExpiry; - this.refreshToken = refreshToken; - this.refreshLocation = refreshLocation; - } + static fromJson(data: ServiceIdentityOAuth): IdentityOAuth { return new IdentityOAuth().load(data); } + clone(): IdentityOAuth { return IdentityOAuth.fromJson(this.toJson()); } + isExpired(): boolean { return this.accessExpiry ? Date.now() / 1000 >= this.accessExpiry : false; } + expiresIn(): number { return this.accessExpiry ? Math.max(0, this.accessExpiry - Date.now() / 1000) : Infinity; } - static fromJson(data: ServiceIdentityOAuth): IdentityOAuth { - return new IdentityOAuth( - data.accessToken, - data.accessScope, - data.accessExpiry, - data.refreshToken, - data.refreshLocation - ); - } - - toJson(): ServiceIdentityOAuth { - return { - type: this.type, - accessToken: this.accessToken, - ...(this.accessScope && { accessScope: this.accessScope }), - ...(this.accessExpiry && { accessExpiry: this.accessExpiry }), - ...(this.refreshToken && { refreshToken: this.refreshToken }), - ...(this.refreshLocation && { refreshLocation: this.refreshLocation }) - }; - } - - isExpired(): boolean { - if (!this.accessExpiry) return false; - return Date.now() / 1000 >= this.accessExpiry; - } - - expiresIn(): number { - if (!this.accessExpiry) return Infinity; - return Math.max(0, this.accessExpiry - Date.now() / 1000); - } + get type(): 'OA' { return this._data.type; } + get accessToken(): string { return this._data.accessToken; } + set accessToken(value: string) { this._data.accessToken = value; } + get accessScope(): string[] | undefined { return this._data.accessScope ? [...this._data.accessScope] : undefined; } + set accessScope(value: string[] | undefined) { this._data.accessScope = value ? [...value] : undefined; } + get accessExpiry(): number | undefined { return this._data.accessExpiry; } + set accessExpiry(value: number | undefined) { this._data.accessExpiry = value; } + get refreshToken(): string | undefined { return this._data.refreshToken; } + set refreshToken(value: string | undefined) { this._data.refreshToken = value; } + get refreshLocation(): string | undefined { return this._data.refreshLocation; } + set refreshLocation(value: string | undefined) { this._data.refreshLocation = value; } } -/** - * Client certificate authentication (mTLS) - */ -export class IdentityCertificate extends Identity { - readonly type = 'CC' as const; - certificate: string; - privateKey: string; - passphrase?: string; +export class IdentityCertificate extends Identity { + constructor(certificate: string = '', privateKey: string = '', passphrase?: string) { + super({ type: 'CC', certificate, privateKey, passphrase }); + } - constructor(certificate: string = '', privateKey: string = '', passphrase?: string) { - super(); - this.certificate = certificate; - this.privateKey = privateKey; - this.passphrase = passphrase; - } - - static fromJson(data: ServiceIdentityCertificate): IdentityCertificate { - return new IdentityCertificate( - data.certificate, - data.privateKey, - data.passphrase - ); - } - - toJson(): ServiceIdentityCertificate { - return { - type: this.type, - certificate: this.certificate, - privateKey: this.privateKey, - ...(this.passphrase && { passphrase: this.passphrase }) - }; - } + static fromJson(data: ServiceIdentityCertificate): IdentityCertificate { return new IdentityCertificate().load(data); } + clone(): IdentityCertificate { return IdentityCertificate.fromJson(this.toJson()); } + get type(): 'CC' { return this._data.type; } + get certificate(): string { return this._data.certificate; } + set certificate(value: string) { this._data.certificate = value; } + get privateKey(): string { return this._data.privateKey; } + set privateKey(value: string) { this._data.privateKey = value; } + get passphrase(): string | undefined { return this._data.passphrase; } + set passphrase(value: string | undefined) { this._data.passphrase = value; } } diff --git a/src/models/index.ts b/src/models/index.ts index ededa2d..85e1e87 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,6 +1,6 @@ export { ProviderObject } from './provider'; export { ServiceObject } from './service'; -export { CollectionObject } from './collection'; +export { CollectionObject, CollectionPropertiesObject } from './collection'; export { EntityObject } from './entity'; export { EventObject } from './event'; export { TaskObject } from './task'; @@ -16,7 +16,6 @@ export { export { Location, LocationUri, - LocationSocketSole, - LocationSocketSplit, LocationFile } from './location'; +export { MutationProxy } from './mutation-proxy'; diff --git a/src/models/location.ts b/src/models/location.ts index 7e19cce..80a87ab 100644 --- a/src/models/location.ts +++ b/src/models/location.ts @@ -1,240 +1,91 @@ /** - * Location implementation classes for Mail Manager services + * Location models for Chrono Manager services */ -import type { - ServiceLocation, - ServiceLocationUri, - ServiceLocationSocketSole, - ServiceLocationSocketSplit, - ServiceLocationFile -} from '@/types/service'; +import type { ServiceLocation, ServiceLocationFile, ServiceLocationUri } from '@/types/service'; +import { clonePlain } from './clone-plain'; +import { MutationProxy } from './mutation-proxy'; -/** - * Base Location class - */ -export abstract class Location { - abstract toJson(): ServiceLocation; - - static fromJson(data: ServiceLocation): Location { - switch (data.type) { - case 'URI': - return LocationUri.fromJson(data); - case 'SOCKET_SOLE': - return LocationSocketSole.fromJson(data); - case 'SOCKET_SPLIT': - return LocationSocketSplit.fromJson(data); - case 'FILE': - return LocationFile.fromJson(data); - default: - throw new Error(`Unknown location type: ${(data as any).type}`); +export abstract class Location { + protected _original: T; + protected _mutated: Partial; + protected _mutationProxy: MutationProxy; + protected _data: T; + + protected constructor(initial: T) { + this._original = clonePlain(initial); + this._mutated = {}; + this._mutationProxy = new MutationProxy(() => this._original, () => this._mutated); + this._data = this._mutationProxy.create(); + } + + protected load(data: T): this { + this._original = clonePlain(data); + this._mutated = {}; + this._data = this._mutationProxy.create(); + return this; + } + + toJson(): T; + toJson(delta: true): Partial; + toJson(delta?: boolean): T | Partial { + return delta + ? clonePlain(this._mutated) + : { ...clonePlain(this._original), ...clonePlain(this._mutated) }; + } + + abstract clone(): Location; + + mutated(): boolean { + return Reflect.ownKeys(this._mutated).length > 0; + } + + static fromJson(data: ServiceLocation): Location { + switch (data.type) { + case 'URI': return LocationUri.fromJson(data); + case 'FILE': return LocationFile.fromJson(data); + default: throw new Error(`Unknown location type: ${(data as any).type}`); + } } - } } -/** - * URI-based service location for API and web services - * Used by: JMAP, Gmail API, etc. - */ -export class LocationUri extends Location { - readonly type = 'URI' as const; - scheme: string; - host: string; - port: number; - path?: string; - verifyPeer: boolean; - verifyHost: boolean; +export class LocationUri extends Location { + constructor( + scheme: string = 'https', + host: string = '', + port: number = 443, + path?: string, + verifyPeer: boolean = true, + verifyHost: boolean = true + ) { + super({ type: 'URI', scheme, host, port, ...(path !== undefined && { path }), verifyPeer, verifyHost }); + } - constructor( - scheme: string = 'https', - host: string = '', - port: number = 443, - path?: string, - verifyPeer: boolean = true, - verifyHost: boolean = true - ) { - super(); - this.scheme = scheme; - this.host = host; - this.port = port; - this.path = path; - this.verifyPeer = verifyPeer; - this.verifyHost = verifyHost; - } + static fromJson(data: ServiceLocationUri): LocationUri { return new LocationUri().load(data); } + clone(): LocationUri { return LocationUri.fromJson(this.toJson()); } + getUrl(): string { return `${this.scheme}://${this.host}:${this.port}${this.path || ''}`; } - static fromJson(data: ServiceLocationUri): LocationUri { - return new LocationUri( - data.scheme, - data.host, - data.port, - data.path, - data.verifyPeer ?? true, - data.verifyHost ?? true - ); - } - - toJson(): ServiceLocationUri { - return { - type: this.type, - scheme: this.scheme, - host: this.host, - port: this.port, - ...(this.path && { path: this.path }), - ...(this.verifyPeer !== undefined && { verifyPeer: this.verifyPeer }), - ...(this.verifyHost !== undefined && { verifyHost: this.verifyHost }) - }; - } - - getUrl(): string { - const path = this.path || ''; - return `${this.scheme}://${this.host}:${this.port}${path}`; - } + get type(): 'URI' { return this._data.type; } + get scheme(): string { return this._data.scheme; } + set scheme(value: string) { this._data.scheme = value; } + get host(): string { return this._data.host; } + set host(value: string) { this._data.host = value; } + get port(): number { return this._data.port; } + set port(value: number) { this._data.port = value; } + get path(): string | undefined { return this._data.path; } + set path(value: string | undefined) { this._data.path = value; } + get verifyPeer(): boolean { return this._data.verifyPeer ?? true; } + set verifyPeer(value: boolean) { this._data.verifyPeer = value; } + get verifyHost(): boolean { return this._data.verifyHost ?? true; } + set verifyHost(value: boolean) { this._data.verifyHost = value; } } -/** - * Single socket-based service location - * Used by: services using a single host/port combination - */ -export class LocationSocketSole extends Location { - readonly type = 'SOCKET_SOLE' as const; - host: string; - port: number; - encryption: 'none' | 'ssl' | 'tls' | 'starttls'; - verifyPeer: boolean; - verifyHost: boolean; +export class LocationFile extends Location { + constructor(path: string = '') { super({ type: 'FILE', path }); } + static fromJson(data: ServiceLocationFile): LocationFile { return new LocationFile().load(data); } + clone(): LocationFile { return LocationFile.fromJson(this.toJson()); } - constructor( - host: string = '', - port: number = 993, - encryption: 'none' | 'ssl' | 'tls' | 'starttls' = 'ssl', - verifyPeer: boolean = true, - verifyHost: boolean = true - ) { - super(); - this.host = host; - this.port = port; - this.encryption = encryption; - this.verifyPeer = verifyPeer; - this.verifyHost = verifyHost; - } - - static fromJson(data: ServiceLocationSocketSole): LocationSocketSole { - return new LocationSocketSole( - data.host, - data.port, - data.encryption, - data.verifyPeer ?? true, - data.verifyHost ?? true - ); - } - - toJson(): ServiceLocationSocketSole { - return { - type: this.type, - host: this.host, - port: this.port, - encryption: this.encryption, - ...(this.verifyPeer !== undefined && { verifyPeer: this.verifyPeer }), - ...(this.verifyHost !== undefined && { verifyHost: this.verifyHost }) - }; - } -} - -/** - * Split socket-based service location - * Used by: traditional IMAP/SMTP configurations - */ -export class LocationSocketSplit extends Location { - readonly type = 'SOCKET_SPLIT' as const; - inboundHost: string; - inboundPort: number; - inboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls'; - outboundHost: string; - outboundPort: number; - outboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls'; - inboundVerifyPeer: boolean; - inboundVerifyHost: boolean; - outboundVerifyPeer: boolean; - outboundVerifyHost: boolean; - - constructor( - inboundHost: string = '', - inboundPort: number = 993, - inboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls' = 'ssl', - outboundHost: string = '', - outboundPort: number = 465, - outboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls' = 'ssl', - inboundVerifyPeer: boolean = true, - inboundVerifyHost: boolean = true, - outboundVerifyPeer: boolean = true, - outboundVerifyHost: boolean = true - ) { - super(); - this.inboundHost = inboundHost; - this.inboundPort = inboundPort; - this.inboundEncryption = inboundEncryption; - this.outboundHost = outboundHost; - this.outboundPort = outboundPort; - this.outboundEncryption = outboundEncryption; - this.inboundVerifyPeer = inboundVerifyPeer; - this.inboundVerifyHost = inboundVerifyHost; - this.outboundVerifyPeer = outboundVerifyPeer; - this.outboundVerifyHost = outboundVerifyHost; - } - - static fromJson(data: ServiceLocationSocketSplit): LocationSocketSplit { - return new LocationSocketSplit( - data.inboundHost, - data.inboundPort, - data.inboundEncryption, - data.outboundHost, - data.outboundPort, - data.outboundEncryption, - data.inboundVerifyPeer ?? true, - data.inboundVerifyHost ?? true, - data.outboundVerifyPeer ?? true, - data.outboundVerifyHost ?? true - ); - } - - toJson(): ServiceLocationSocketSplit { - return { - type: this.type, - inboundHost: this.inboundHost, - inboundPort: this.inboundPort, - inboundEncryption: this.inboundEncryption, - outboundHost: this.outboundHost, - outboundPort: this.outboundPort, - outboundEncryption: this.outboundEncryption, - ...(this.inboundVerifyPeer !== undefined && { inboundVerifyPeer: this.inboundVerifyPeer }), - ...(this.inboundVerifyHost !== undefined && { inboundVerifyHost: this.inboundVerifyHost }), - ...(this.outboundVerifyPeer !== undefined && { outboundVerifyPeer: this.outboundVerifyPeer }), - ...(this.outboundVerifyHost !== undefined && { outboundVerifyHost: this.outboundVerifyHost }) - }; - } -} - -/** - * File-based service location - * Used by: local file system providers - */ -export class LocationFile extends Location { - readonly type = 'FILE' as const; - path: string; - - constructor(path: string = '') { - super(); - this.path = path; - } - - static fromJson(data: ServiceLocationFile): LocationFile { - return new LocationFile(data.path); - } - - toJson(): ServiceLocationFile { - return { - type: this.type, - path: this.path - }; - } + get type(): 'FILE' { return this._data.type; } + get path(): string { return this._data.path; } + set path(value: string) { this._data.path = value; } } diff --git a/src/models/mutation-proxy.ts b/src/models/mutation-proxy.ts new file mode 100644 index 0000000..a91fb8c --- /dev/null +++ b/src/models/mutation-proxy.ts @@ -0,0 +1,36 @@ +import { clonePlain } from './clone-plain'; + +export class MutationProxy { + constructor( + private readonly getOriginal: () => T, + private readonly getMutated: () => Partial, + ) {} + + create(): T { + return new Proxy({} as T, { + get: (_target, prop: string | symbol) => { + if (typeof prop !== 'string') return undefined; + + const key = prop as keyof T; + const mutated = this.getMutated(); + return key in mutated ? mutated[key] : this.getOriginal()[key]; + }, + set: (_target, prop: string | symbol, value: unknown) => { + if (typeof prop === 'string') { + const key = prop as keyof T; + (this.getMutated() as Record)[key] = clonePlain(value); + } + return true; + }, + has: (_target, prop: string | symbol) => { + if (typeof prop !== 'string') return false; + return prop in this.getMutated() || prop in this.getOriginal(); + }, + ownKeys: () => Array.from(new Set([ + ...Reflect.ownKeys(this.getOriginal()), + ...Reflect.ownKeys(this.getMutated()), + ])), + getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true }), + }); + } +} diff --git a/src/models/provider.ts b/src/models/provider.ts index 0b223f8..85579d4 100644 --- a/src/models/provider.ts +++ b/src/models/provider.ts @@ -4,10 +4,12 @@ import type { ProviderInterface, - ProviderCapabilitiesInterface + ProviderCapabilitiesInterface, + ProviderModelInterface } from "@/types/provider"; +import { clonePlain } from './clone-plain'; -export class ProviderObject implements ProviderInterface { +export class ProviderObject implements ProviderModelInterface { _data!: ProviderInterface; @@ -21,12 +23,16 @@ export class ProviderObject implements ProviderInterface { } fromJson(data: ProviderInterface): ProviderObject { - this._data = data; + this._data = clonePlain(data); return this; } toJson(): ProviderInterface { - return this._data; + return clonePlain(this._data); + } + + clone(): ProviderObject { + return new ProviderObject().fromJson(this.toJson()); } capable(capability: keyof ProviderCapabilitiesInterface): boolean { @@ -43,10 +49,6 @@ export class ProviderObject implements ProviderInterface { /** Immutable Properties */ - get '@type'(): string { - return this._data['@type']; - } - get identifier(): string { return this._data.identifier; } @@ -56,7 +58,7 @@ export class ProviderObject implements ProviderInterface { } get capabilities(): ProviderCapabilitiesInterface { - return this._data.capabilities; + return clonePlain(this._data.capabilities); } } diff --git a/src/models/service.ts b/src/models/service.ts index e6e2609..af265cb 100644 --- a/src/models/service.ts +++ b/src/models/service.ts @@ -5,18 +5,26 @@ import type { ServiceInterface, ServiceCapabilitiesInterface, - ServiceIdentity, - ServiceLocation + ServiceLocation, + ServiceModelInterface } from "@/types/service"; import { Identity } from './identity'; import { Location } from './location'; +import { MutationProxy } from './mutation-proxy'; +import { clonePlain } from './clone-plain'; -export class ServiceObject implements ServiceInterface { - +export class ServiceObject implements ServiceModelInterface { + private _original: ServiceInterface; + private _mutated: Partial; + private _mutationProxy = new MutationProxy(() => this._original, () => this._mutated); _data!: ServiceInterface; + private _location: Location | null | undefined = undefined; + private _identity: Identity | null | undefined = undefined; + private _locationAssigned = false; + private _identityAssigned = false; constructor() { - this._data = { + this._original = { '@type': 'chrono:service', provider: '', identifier: null, @@ -24,15 +32,59 @@ export class ServiceObject implements ServiceInterface { enabled: false, capabilities: {} }; + this._mutated = {}; + this._data = this._mutationProxy.create(); } fromJson(data: ServiceInterface): ServiceObject { - this._data = data; + this._original = clonePlain(data); + this._mutated = {}; + this._data = this._mutationProxy.create(); + this._location = undefined; + this._identity = undefined; + this._locationAssigned = false; + this._identityAssigned = false; return this; } - toJson(): ServiceInterface { - return this._data; + toJson(): ServiceInterface; + toJson(delta: true): Partial; + toJson(delta?: boolean): ServiceInterface | Partial { + if (delta) { + const json = clonePlain(this._mutated); + if (this._locationAssigned) { + json.location = this._location ? this._location.toJson() : null; + } else if (this._location?.mutated()) { + json.location = this._location.toJson(true) as ServiceInterface['location']; + } + + if (this._identityAssigned) { + json.identity = this._identity ? this._identity.toJson() : null; + } else if (this._identity?.mutated()) { + json.identity = this._identity.toJson(true) as ServiceInterface['identity']; + } + return json; + } + + const json: ServiceInterface = { + ...clonePlain(this._original), + ...clonePlain(this._mutated), + }; + if (this._location !== undefined) json.location = this._location ? this._location.toJson() : null; + if (this._identity !== undefined) json.identity = this._identity ? this._identity.toJson() : null; + return json; + } + + clone(): ServiceObject { + return new ServiceObject().fromJson(this.toJson()); + } + + mutated(): boolean { + return Reflect.ownKeys(this._mutated).length > 0 + || this._locationAssigned + || this._identityAssigned + || (this._location?.mutated() ?? false) + || (this._identity?.mutated() ?? false); } capable(capability: keyof ServiceCapabilitiesInterface): boolean { @@ -41,88 +93,41 @@ export class ServiceObject implements ServiceInterface { } capability(capability: keyof ServiceCapabilitiesInterface): any | null { - if (this._data.capabilities) { - return this._data.capabilities[capability]; - } - return null; + return this._data.capabilities?.[capability] ?? null; } - /** Immutable Properties */ + get provider(): string { return this._data.provider; } + get identifier(): string | number | null { return this._data.identifier; } + get capabilities(): ServiceCapabilitiesInterface { return this._data.capabilities ?? {}; } - get '@type'(): string { - return this._data['@type']; + get label(): string | null { return this._data.label; } + set label(value: string | null) { this._data.label = value; } + + get enabled(): boolean { return this._data.enabled; } + set enabled(value: boolean) { this._data.enabled = value; } + + get location(): Location | null { + if (this._location !== undefined) return this._location; + this._location = this._data.location ? Location.fromJson(this._data.location as ServiceLocation) : null; + return this._location; } - get provider(): string { - return this._data.provider; + set location(value: Location | null) { + this._location = value; + this._locationAssigned = true; } - get identifier(): string | number | null { - return this._data.identifier; + get identity(): Identity | null { + if (this._identity !== undefined) return this._identity; + this._identity = this._data.identity ? Identity.fromJson(this._data.identity) : null; + return this._identity; } - get capabilities(): ServiceCapabilitiesInterface | undefined { - return this._data.capabilities; - } - - /** Mutable Properties */ - - get label(): string | null { - return this._data.label; - } - - set label(value: string | null) { - this._data.label = value; - } - - get enabled(): boolean { - return this._data.enabled; - } - - set enabled(value: boolean) { - this._data.enabled = value; - } - - get location(): ServiceLocation | null { - return this._data.location ?? null; - } - - set location(value: ServiceLocation | null) { - this._data.location = value; - } - - get identity(): ServiceIdentity | null { - return this._data.identity ?? null; - } - - set identity(value: ServiceIdentity | null) { - this._data.identity = value; - } - - get auxiliary(): Record { - return this._data.auxiliary ?? {}; - } - - set auxiliary(value: Record) { - this._data.auxiliary = value; - } - - /** Helper Methods */ - - /** - * Get identity as a class instance for easier manipulation - */ - getIdentity(): Identity | null { - if (!this._data.identity) return null; - return Identity.fromJson(this._data.identity); - } - - /** - * Get location as a class instance for easier manipulation - */ - getLocation(): Location | null { - if (!this._data.location) return null; - return Location.fromJson(this._data.location); + set identity(value: Identity | null) { + this._identity = value; + this._identityAssigned = true; } + get auxiliary(): Record { return this._data.auxiliary ?? {}; } + set auxiliary(value: Record) { this._data.auxiliary = value; } } diff --git a/src/services/collectionService.ts b/src/services/collectionService.ts index c91f5c0..817c020 100644 --- a/src/services/collectionService.ts +++ b/src/services/collectionService.ts @@ -70,9 +70,15 @@ export const collectionService = { * * @returns Promise with collection object */ - async fetch(request: CollectionFetchRequest): Promise { + async fetch(request: CollectionFetchRequest): Promise> { const response = await transceivePost('collection.fetch', request); - return createCollectionObject(response); + + const list: Record = {}; + Object.entries(response).forEach(([, collection]) => { + list[collection.identifier] = createCollectionObject(collection); + }); + + return list; }, /** @@ -123,8 +129,14 @@ export const collectionService = { * * @returns Promise with deletion result */ - async delete(request: CollectionDeleteRequest): Promise { - return await transceivePost('collection.delete', request); + async delete(request: CollectionDeleteRequest): Promise { + const response = await transceivePost('collection.delete', request); + + if (response.disposition === 'moved' && response.mutation) { + return createCollectionObject(response.mutation); + } + + return true; }, }; diff --git a/src/services/entityService.ts b/src/services/entityService.ts index f69051b..3518696 100644 --- a/src/services/entityService.ts +++ b/src/services/entityService.ts @@ -2,10 +2,12 @@ * Entity management service */ -import { transceivePost } from './transceive'; +import { transceivePost, transceiveStream } from './transceive'; import type { - EntityListRequest, - EntityListResponse, + EntityListBulkRequest, + EntityListBulkResponse, + EntityListStreamRequest, + EntityListStreamResponse, EntityFetchRequest, EntityFetchResponse, EntityExtantRequest, @@ -18,6 +20,8 @@ import type { EntityDeleteResponse, EntityDeltaRequest, EntityDeltaResponse, + EntityMoveRequest, + EntityMoveResponse, EntityInterface, } from '../types/entity'; import { useIntegrationStore } from '@KTXC/stores/integrationStore'; @@ -31,7 +35,7 @@ function createEntityObject(data: EntityInterface): EntityObject { const integrationStore = useIntegrationStore(); const factoryItem = integrationStore.getItemById('chrono_entity_factory', data.provider) as any; const factory = factoryItem?.factory; - + // Use provider factory if available, otherwise base class return factory ? factory(data) : new EntityObject().fromJson(data); } @@ -39,16 +43,11 @@ function createEntityObject(data: EntityInterface): EntityObject { export const entityService = { /** - * Retrieve list of entities, optionally filtered by source selector - * - * @param request - list request parameters - * - * @returns Promise with entity object list grouped by provider, service, collection, and entity identifier + * Retrieve list of entities, optionally filtered by source collection identifiers */ - async list(request: EntityListRequest = {}): Promise>>>> { - const response = await transceivePost('entity.list', request); - - // Convert nested response to EntityObject instances + async listBulk(request: EntityListBulkRequest = {}): Promise>>>> { + const response = await transceivePost('entity.listBulk', request); + const providerList: Record>>> = {}; Object.entries(response).forEach(([providerId, providerServices]) => { const serviceList: Record>> = {}; @@ -65,35 +64,39 @@ export const entityService = { }); providerList[providerId] = serviceList; }); - + return providerList; }, /** - * Retrieve a specific entity by provider and identifier - * - * @param request - fetch request parameters - * - * @returns Promise with entity objects keyed by identifier + * Stream entities as NDJSON, invoking onEntity for each entity as it arrives. + */ + async listStream(request: EntityListStreamRequest, onEntity: (entity: EntityObject) => void): Promise<{ total: number }> { + return await transceiveStream( + 'entity.listStream', + request, + (entity) => { + onEntity(createEntityObject(entity)); + } + ); + }, + + /** + * Retrieve specific entities by their identifiers */ async fetch(request: EntityFetchRequest): Promise> { const response = await transceivePost('entity.fetch', request); - - // Convert response to EntityObject instances + const list: Record = {}; - Object.entries(response).forEach(([identifier, entityData]) => { - list[identifier] = createEntityObject(entityData); + Object.entries(response).forEach(([, entity]) => { + list[entity.identifier] = createEntityObject(entity); }); - + return list; }, /** - * Retrieve entity availability status for a given source selector - * - * @param request - extant request parameters - * - * @returns Promise with entity availability status + * Retrieve entity availability status for a given set of entity identifiers */ async extant(request: EntityExtantRequest): Promise { return await transceivePost('entity.extant', request); @@ -101,10 +104,6 @@ export const entityService = { /** * Create a new entity - * - * @param request - create request parameters - * - * @returns Promise with created entity object */ async create(request: EntityCreateRequest): Promise { const response = await transceivePost('entity.create', request); @@ -113,10 +112,6 @@ export const entityService = { /** * Update an existing entity - * - * @param request - update request parameters - * - * @returns Promise with updated entity object */ async update(request: EntityUpdateRequest): Promise { const response = await transceivePost('entity.update', request); @@ -124,11 +119,7 @@ export const entityService = { }, /** - * Delete an entity - * - * @param request - delete request parameters - * - * @returns Promise with deletion result + * Delete entities by their identifiers */ async delete(request: EntityDeleteRequest): Promise { return await transceivePost('entity.delete', request); @@ -136,15 +127,18 @@ export const entityService = { /** * Retrieve delta changes for entities - * - * @param request - delta request parameters - * - * @returns Promise with delta changes (created, modified, deleted) */ async delta(request: EntityDeltaRequest): Promise { return await transceivePost('entity.delta', request); }, + /** + * Move entities to a target collection + */ + async move(request: EntityMoveRequest): Promise { + return await transceivePost('entity.move', request); + }, + }; export default entityService; diff --git a/src/services/transceive.ts b/src/services/transceive.ts index 98d3f67..bde52a3 100644 --- a/src/services/transceive.ts +++ b/src/services/transceive.ts @@ -4,7 +4,7 @@ */ import { createFetchWrapper } from '@KTXC'; -import type { ApiRequest, ApiResponse } from '../types/common'; +import type { ApiRequest, ApiResponse, ApiStreamResponse } from '../types/common'; const fetchWrapper = createFetchWrapper(); const API_URL = '/m/chrono_manager/v1'; @@ -48,3 +48,82 @@ export async function transceivePost( return response.data; } + +/** + * Stream an NDJSON API response, unwrapping data frames for the caller. + * + * @param operation - Operation name, e.g. 'entity.listStream' + * @param data - Operation-specific request data + * @param onData - Synchronous callback invoked for every unwrapped data payload. + * @param options - Optional `user` override and an `onStart` hook. + * @returns Promise resolving to the final stream total from the control/end frame + */ +export async function transceiveStream( + operation: string, + data: TRequest, + onData: (data: TData) => void, + options?: { user?: string; onStart?: (expected?: number) => void } +): Promise<{ total: number }> { + const request: ApiRequest = { + version: API_VERSION, + transaction: generateTransactionId(), + operation, + data, + user: options?.user, + }; + + let total = 0; + + const dispatch = (line: string): void => { + const message = JSON.parse(line) as ApiStreamResponse; + + if (message.type === 'control') { + if (message.status === 'start') { + options?.onStart?.(message.total); + } else if (message.status === 'end') { + total = message.total; + } + return; + } + + if (message.type === 'error') { + throw new Error(`[${operation}] ${message.message}`); + } + + onData(message.data); + }; + + await fetchWrapper.post(API_URL, request, { + headers: { 'Accept': 'application/json' }, + onStream: async (response: Response) => { + if (!response.body) { + throw new Error(`[${operation}] Response body is not readable`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop()!; + + for (const line of lines) { + if (line.trim()) dispatch(line); + } + } + + if (buffer.trim()) dispatch(buffer); + } finally { + reader.releaseLock(); + } + }, + }); + + return { total }; +} diff --git a/src/stores/collectionsStore.ts b/src/stores/collectionsStore.ts index 6835f53..9b6b517 100644 --- a/src/stores/collectionsStore.ts +++ b/src/stores/collectionsStore.ts @@ -4,9 +4,14 @@ import { ref, computed, readonly } from 'vue' import { defineStore } from 'pinia' +import { + type ServiceIdentifier, + type CollectionIdentifier, + type ListFilter, + type ListSort, +} from '../types' import { collectionService } from '../services' import { CollectionObject, CollectionPropertiesObject } from '../models/collection' -import type { SourceSelector, ListFilter, ListSort } from '../types' export const useCollectionsStore = defineStore('chronoCollectionsStore', () => { // State @@ -33,101 +38,61 @@ export const useCollectionsStore = defineStore('chronoCollectionsStore', () => { */ const collectionsByService = computed(() => { const groups: Record = {} - + Object.values(_collections.value).forEach((collection) => { const serviceKey = `${collection.provider}:${collection.service}` - if (!groups[serviceKey]) { - groups[serviceKey] = [] - } - groups[serviceKey].push(collection) + const serviceCollections = (groups[serviceKey] ??= []) + serviceCollections.push(collection) }) - + return groups }) /** * Get a specific collection from store, with optional retrieval - * - * @param provider - provider identifier - * @param service - service identifier - * @param identifier - collection identifier - * @param retrieve - Retrieve behavior: true = fetch if missing or refresh, false = cache only - * - * @returns Collection object or null */ - function collection(provider: string, service: string | number, identifier: string | number, retrieve: boolean = false): CollectionObject | null { - const key = identifierKey(provider, service, identifier) - if (retrieve === true && !_collections.value[key]) { - console.debug(`[Chrono Manager][Store] - Force fetching collection "${key}"`) - fetch(provider, service, identifier) + function collection(target: CollectionIdentifier, retrieve: boolean = false): CollectionObject | null { + if (retrieve === true && !_collections.value[target]) { + console.debug(`[Chrono Manager][Store] - Force fetching collection "${target}"`) + fetch([target]) } - - return _collections.value[key] || null + + return _collections.value[target] || null } /** * Get all collections for a specific service - * - * @param provider - provider identifier - * @param service - service identifier - * @param retrieve - Retrieve behavior: true = fetch if missing or refresh, false = cache only - * - * @returns Array of collection objects */ function collectionsForService(provider: string, service: string | number, retrieve: boolean = false): CollectionObject[] { - const serviceKeyPrefix = `${provider}:${service}:` - const serviceCollections = Object.entries(_collections.value) - .filter(([key]) => key.startsWith(serviceKeyPrefix)) - .map(([_, collection]) => collection) - + const serviceIdentifier = `${provider}:${service}` as ServiceIdentifier + const serviceCollections = Object.values(_collections.value) + .filter(collection => `${collection.provider}:${collection.service}` === serviceIdentifier) + if (retrieve === true && serviceCollections.length === 0) { - console.debug(`[Chrono Manager][Store] - Force fetching collections for service "${provider}:${service}"`) - const sources: SourceSelector = { - [provider]: { - [String(service)]: true - } - } - list(sources) + console.debug(`[Chrono Manager][Store] - Force fetching collections for service "${serviceIdentifier}"`) + list([serviceIdentifier]) } - + return serviceCollections } /** - * Create unique key for a collection + * Retrieve all or specific collections, optionally filtered by service/collection identifiers */ - function identifierKey(provider: string, service: string | number | null, identifier: string | number | null): string { - return `${provider}:${service ?? ''}:${identifier ?? ''}` - } - - // Actions - - /** - * Retrieve all or specific collections, optionally filtered by source selector - * - * @param sources - optional source selector - * @param filter - optional list filter - * @param sort - optional list sort - * - * @returns Promise with collection object list keyed by provider, service, and collection identifier - */ - async function list(sources?: SourceSelector, filter?: ListFilter, sort?: ListSort): Promise> { + async function list(sources?: ServiceIdentifier[] | CollectionIdentifier[], filter?: ListFilter, sort?: ListSort): Promise> { transceiving.value = true try { const response = await collectionService.list({ sources, filter, sort }) - // Flatten nested structure: provider:service:collection -> "provider:service:collection": object const collections: Record = {} Object.entries(response).forEach(([_providerId, providerServices]) => { Object.entries(providerServices).forEach(([_serviceId, serviceCollections]) => { Object.entries(serviceCollections).forEach(([_collectionId, collectionObj]) => { - const key = identifierKey(collectionObj.provider, collectionObj.service, collectionObj.identifier) - collections[key] = collectionObj + collections[collectionObj.identifier] = collectionObj }) }) }) - // Merge retrieved collections into state _collections.value = { ..._collections.value, ...collections } console.debug('[Chrono Manager][Store] - Successfully retrieved', Object.keys(collections).length, 'collections') @@ -139,29 +104,23 @@ export const useCollectionsStore = defineStore('chronoCollectionsStore', () => { transceiving.value = false } } - + /** - * Retrieve a specific collection by provider, service, and identifier - * - * @param provider - provider identifier - * @param service - service identifier - * @param identifier - collection identifier - * - * @returns Promise with collection object + * Retrieve specific collections by their identifiers */ - async function fetch(provider: string, service: string | number, identifier: string | number): Promise { + async function fetch(targets: CollectionIdentifier[]): Promise> { transceiving.value = true try { - const response = await collectionService.fetch({ provider, service, collection: identifier }) - - // Merge fetched collection into state - const key = identifierKey(response.provider, response.service, response.identifier) - _collections.value[key] = response + const response = await collectionService.fetch({ targets }) - console.debug('[Chrono Manager][Store] - Successfully fetched collection:', key) + Object.values(response).forEach(collectionObj => { + _collections.value[collectionObj.identifier] = collectionObj + }) + + console.debug('[Chrono Manager][Store] - Successfully fetched collections:', Object.keys(response).join(', ')) return response } catch (error: any) { - console.error('[Chrono Manager][Store] - Failed to fetch collection:', error) + console.error('[Chrono Manager][Store] - Failed to fetch collections:', error) throw error } finally { transceiving.value = false @@ -169,18 +128,14 @@ export const useCollectionsStore = defineStore('chronoCollectionsStore', () => { } /** - * Retrieve collection availability status for a given source selector - * - * @param sources - source selector to check availability for - * - * @returns Promise with collection availability status + * Retrieve collection availability status for the given collection identifiers */ - async function extant(sources: SourceSelector) { + async function extant(targets: CollectionIdentifier[]): Promise>>> { transceiving.value = true try { - const response = await collectionService.extant({ sources }) - - console.debug('[Chrono Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'collections') + const response = await collectionService.extant({ targets }) + + console.debug('[Chrono Manager][Store] - Successfully checked', targets ? targets.length : 0, 'collections') return response } catch (error: any) { console.error('[Chrono Manager][Store] - Failed to check collections:', error) @@ -191,30 +146,20 @@ export const useCollectionsStore = defineStore('chronoCollectionsStore', () => { } /** - * Create a new collection with given provider, service, and data - * - * @param provider - provider identifier for the new collection - * @param service - service identifier for the new collection - * @param collection - optional parent collection identifier - * @param data - collection properties for creation - * - * @returns Promise with created collection object + * Create a new collection with given provider, service, and properties */ - async function create(provider: string, service: string | number, collection: string | number | null, data: CollectionPropertiesObject): Promise { + async function create(provider: string, service: string | number, properties: CollectionPropertiesObject): Promise { transceiving.value = true try { - const response = await collectionService.create({ - provider, - service, - collection, - properties: data + const response = await collectionService.create({ + provider, + service, + properties: properties.toJson(), }) - - // Merge created collection into state - const key = identifierKey(response.provider, response.service, response.identifier) - _collections.value[key] = response - - console.debug('[Chrono Manager][Store] - Successfully created collection:', key) + + _collections.value[response.identifier] = response + + console.debug('[Chrono Manager][Store] - Successfully created collection:', response.identifier) return response } catch (error: any) { console.error('[Chrono Manager][Store] - Failed to create collection:', error) @@ -225,30 +170,19 @@ export const useCollectionsStore = defineStore('chronoCollectionsStore', () => { } /** - * Update an existing collection with given provider, service, identifier, and data - * - * @param provider - provider identifier for the collection to update - * @param service - service identifier for the collection to update - * @param identifier - collection identifier for the collection to update - * @param data - collection properties for update - * - * @returns Promise with updated collection object + * Update an existing collection with given target and properties */ - async function update(provider: string, service: string | number, identifier: string | number, data: CollectionPropertiesObject): Promise { + async function update(target: CollectionIdentifier, properties: CollectionPropertiesObject): Promise { transceiving.value = true try { - const response = await collectionService.update({ - provider, - service, - identifier, - properties: data + const response = await collectionService.update({ + target, + properties: properties.toJson(), }) - - // Merge updated collection into state - const key = identifierKey(response.provider, response.service, response.identifier) - _collections.value[key] = response - - console.debug('[Chrono Manager][Store] - Successfully updated collection:', key) + + _collections.value[response.identifier] = response + + console.debug('[Chrono Manager][Store] - Successfully updated collection:', response.identifier) return response } catch (error: any) { console.error('[Chrono Manager][Store] - Failed to update collection:', error) @@ -259,24 +193,28 @@ export const useCollectionsStore = defineStore('chronoCollectionsStore', () => { } /** - * Delete a collection by provider, service, and identifier - * - * @param provider - provider identifier for the collection to delete - * @param service - service identifier for the collection to delete - * @param identifier - collection identifier for the collection to delete - * - * @returns Promise with deletion result + * Delete a collection by identifier. */ - async function remove(provider: string, service: string | number, identifier: string | number): Promise { + async function remove(target: CollectionIdentifier, force?: boolean): Promise { transceiving.value = true try { - await collectionService.delete({ provider, service, identifier }) - - // Remove deleted collection from state - const key = identifierKey(provider, service, identifier) - delete _collections.value[key] + const response = await collectionService.delete({ target, options: { force } }) - console.debug('[Chrono Manager][Store] - Successfully deleted collection:', key) + if (response !== true && !(response instanceof CollectionObject)) { + console.warn('[Chrono Manager][Store] - Delete failed. Received unexpected response from delete operation:', response) + return false + } + + delete _collections.value[target] + + if (response instanceof CollectionObject) { + _collections.value[response.identifier] = response + console.debug('[Chrono Manager][Store] - Successfully moved collection', target, '->', response.identifier) + return response + } + + console.debug('[Chrono Manager][Store] - Successfully deleted collection:', target) + return response } catch (error: any) { console.error('[Chrono Manager][Store] - Failed to delete collection:', error) throw error @@ -285,17 +223,13 @@ export const useCollectionsStore = defineStore('chronoCollectionsStore', () => { } } - // Return public API return { - // State (readonly) transceiving: readonly(transceiving), - // Getters count, has, collections, collectionsByService, collectionsForService, - // Actions collection, list, fetch, diff --git a/src/stores/entitiesStore.ts b/src/stores/entitiesStore.ts index 7a4a501..8b86fba 100644 --- a/src/stores/entitiesStore.ts +++ b/src/stores/entitiesStore.ts @@ -6,7 +6,14 @@ import { ref, computed, readonly } from 'vue' import { defineStore } from 'pinia' import { entityService } from '../services' import { EntityObject } from '../models' -import type { SourceSelector, ListFilter, ListSort, ListRange } from '../types/common' +import type { + CollectionIdentifier, + EntityIdentifier, + ListFilter, + ListRange, + ListSort, +} from '../types/common' +import type { EntityPropertiesInterface } from '@/types/entity' export const useEntitiesStore = defineStore('chronoEntitiesStore', () => { // State @@ -30,95 +37,44 @@ export const useEntitiesStore = defineStore('chronoEntitiesStore', () => { /** * Get a specific entity from store, with optional retrieval - * - * @param provider - provider identifier - * @param service - service identifier - * @param collection - collection identifier - * @param identifier - entity identifier - * @param retrieve - Retrieve behavior: true = fetch if missing or refresh, false = cache only - * - * @returns Entity object or null */ - function entity(provider: string, service: string | number, collection: string | number, identifier: string | number, retrieve: boolean = false): EntityObject | null { - const key = identifierKey(provider, service, collection, identifier) - if (retrieve === true && !_entities.value[key]) { - console.debug(`[Chrono Manager][Store] - Force fetching entity "${key}"`) - fetch(provider, service, collection, [identifier]) + function entity(target: EntityIdentifier, retrieve: boolean = false): EntityObject | null { + if (retrieve === true && !_entities.value[target]) { + console.debug(`[Chrono Manager][Store] - Force fetching entity "${target}"`) + fetch([target]) } - - return _entities.value[key] || null + + return _entities.value[target] || null } /** * Get all entities for a specific collection - * - * @param provider - provider identifier - * @param service - service identifier - * @param collection - collection identifier - * @param retrieve - Retrieve behavior: true = fetch if missing or refresh, false = cache only - * - * @returns Array of entity objects */ - function entitiesForCollection(provider: string, service: string | number, collection: string | number, retrieve: boolean = false): EntityObject[] { - const collectionKeyPrefix = `${provider}:${service}:${collection}:` + function entitiesForCollection(target: CollectionIdentifier, retrieve: boolean = false): EntityObject[] { const collectionEntities = Object.entries(_entities.value) - .filter(([key]) => key.startsWith(collectionKeyPrefix)) + .filter(([key]) => key.startsWith(`${target}:`)) .map(([_, entity]) => entity) - + if (retrieve === true && collectionEntities.length === 0) { - console.debug(`[Chrono Manager][Store] - Force fetching entities for collection "${provider}:${service}:${collection}"`) - const sources: SourceSelector = { - [provider]: { - [String(service)]: { - [String(collection)]: true - } - } - } - list(sources) + console.debug(`[Chrono Manager][Store] - Force fetching entities for collection "${target}"`) + list([target]) } - + return collectionEntities } /** - * Create unique key for an entity + * Retrieve all or specific entities, optionally filtered by source collection identifiers */ - function identifierKey(provider: string, service: string | number, collection: string | number, identifier: string | number): string { - return `${provider}:${service}:${collection}:${identifier}` - } - - // Actions - - /** - * Retrieve all or specific entities, optionally filtered by source selector - * - * @param sources - optional source selector - * @param filter - optional list filter - * @param sort - optional list sort - * @param range - optional list range - * - * @returns Promise with entity object list keyed by identifier - */ - async function list(sources?: SourceSelector, filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise> { + async function list(sources: CollectionIdentifier[], filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise> { transceiving.value = true try { - const response = await entityService.list({ sources, filter, sort, range }) - - // Flatten nested structure: provider:service:collection:entity -> "provider:service:collection:entity": object const entities: Record = {} - Object.entries(response).forEach(([providerId, providerServices]) => { - Object.entries(providerServices).forEach(([serviceId, serviceCollections]) => { - Object.entries(serviceCollections).forEach(([collectionId, collectionEntities]) => { - Object.entries(collectionEntities).forEach(([entityId, entityData]) => { - const key = identifierKey(providerId, serviceId, collectionId, entityId) - entities[key] = entityData - }) - }) - }) - }) - // Merge retrieved entities into state - _entities.value = { ..._entities.value, ...entities } + await entityService.listStream({ sources, filter, sort, range }, (entity: EntityObject) => { + _entities.value[entity.identifier] = entity + entities[entity.identifier] = entity + }) console.debug('[Chrono Manager][Store] - Successfully retrieved', Object.keys(entities).length, 'entities') return entities @@ -129,28 +85,19 @@ export const useEntitiesStore = defineStore('chronoEntitiesStore', () => { transceiving.value = false } } - + /** - * Retrieve specific entities by provider, service, collection, and identifiers - * - * @param provider - provider identifier - * @param service - service identifier - * @param collection - collection identifier - * @param identifiers - array of entity identifiers to fetch - * - * @returns Promise with entity objects keyed by identifier + * Retrieve specific entities by their identifiers */ - async function fetch(provider: string, service: string | number, collection: string | number, identifiers: (string | number)[]): Promise> { + async function fetch(targets: EntityIdentifier[]): Promise> { transceiving.value = true try { - const response = await entityService.fetch({ provider, service, collection, identifiers }) - - // Merge fetched entities into state + const response = await entityService.fetch({ targets }) + const entities: Record = {} - Object.entries(response).forEach(([identifier, entityData]) => { - const key = identifierKey(provider, service, collection, identifier) - entities[key] = entityData - _entities.value[key] = entityData + Object.entries(response).forEach(([identifier, entity]) => { + entities[identifier] = entity + _entities.value[identifier] = entity }) console.debug('[Chrono Manager][Store] - Successfully fetched', Object.keys(entities).length, 'entities') @@ -164,16 +111,12 @@ export const useEntitiesStore = defineStore('chronoEntitiesStore', () => { } /** - * Retrieve entity availability status for a given source selector - * - * @param sources - source selector to check availability for - * - * @returns Promise with entity availability status + * Retrieve entity availability status for a given set of entity identifiers */ - async function extant(sources: SourceSelector) { + async function extant(targets: EntityIdentifier[]) { transceiving.value = true try { - const response = await entityService.extant({ sources }) + const response = await entityService.extant({ targets }) console.debug('[Chrono Manager][Store] - Successfully checked entity availability') return response } catch (error: any) { @@ -184,132 +127,28 @@ export const useEntitiesStore = defineStore('chronoEntitiesStore', () => { } } - /** - * Create a new entity with given provider, service, collection, and data - * - * @param provider - provider identifier for the new entity - * @param service - service identifier for the new entity - * @param collection - collection identifier for the new entity - * @param data - entity properties for creation - * - * @returns Promise with created entity object - */ - async function create(provider: string, service: string | number, collection: string | number, data: any): Promise { - transceiving.value = true - try { - const response = await entityService.create({ provider, service, collection, properties: data }) - - // Add created entity to state - const key = identifierKey(response.provider, response.service, response.collection, response.identifier) - _entities.value[key] = response - - console.debug('[Chrono Manager][Store] - Successfully created entity:', key) - return response - } catch (error: any) { - console.error('[Chrono Manager][Store] - Failed to create entity:', error) - throw error - } finally { - transceiving.value = false - } - } - - /** - * Update an existing entity with given provider, service, collection, identifier, and data - * - * @param provider - provider identifier for the entity to update - * @param service - service identifier for the entity to update - * @param collection - collection identifier for the entity to update - * @param identifier - entity identifier for the entity to update - * @param data - entity properties for update - * - * @returns Promise with updated entity object - */ - async function update(provider: string, service: string | number, collection: string | number, identifier: string | number, data: any): Promise { - transceiving.value = true - try { - const response = await entityService.update({ provider, service, collection, identifier, properties: data }) - - // Update entity in state - const key = identifierKey(response.provider, response.service, response.collection, response.identifier) - _entities.value[key] = response - - console.debug('[Chrono Manager][Store] - Successfully updated entity:', key) - return response - } catch (error: any) { - console.error('[Chrono Manager][Store] - Failed to update entity:', error) - throw error - } finally { - transceiving.value = false - } - } - - /** - * Delete an entity by provider, service, collection, and identifier - * - * @param provider - provider identifier for the entity to delete - * @param service - service identifier for the entity to delete - * @param collection - collection identifier for the entity to delete - * @param identifier - entity identifier for the entity to delete - * - * @returns Promise with deletion result - */ - async function remove(provider: string, service: string | number, collection: string | number, identifier: string | number): Promise { - transceiving.value = true - try { - const response = await entityService.delete({ provider, service, collection, identifier }) - - // Remove entity from state - const key = identifierKey(provider, service, collection, identifier) - delete _entities.value[key] - - console.debug('[Chrono Manager][Store] - Successfully deleted entity:', key) - return response - } catch (error: any) { - console.error('[Chrono Manager][Store] - Failed to delete entity:', error) - throw error - } finally { - transceiving.value = false - } - } - /** * Retrieve delta changes for entities - * - * @param sources - source selector for delta check - * - * @returns Promise with delta changes (additions, modifications, deletions) - * - * Note: Delta returns only identifiers, not full entities. - * Caller should fetch full entities for additions/modifications separately. */ - async function delta(sources: SourceSelector) { + async function delta(targets: (CollectionIdentifier | EntityIdentifier)[]) { transceiving.value = true try { - const response = await entityService.delta({ sources }) - - // Process delta and update store - Object.entries(response).forEach(([provider, providerData]) => { - // Skip if no changes for provider + const response = await entityService.delta({ targets }) + + Object.entries(response).forEach(([, providerData]) => { if (providerData === false) return - - Object.entries(providerData).forEach(([service, serviceData]) => { - // Skip if no changes for service + + Object.entries(providerData).forEach(([, serviceData]) => { if (serviceData === false) return - - Object.entries(serviceData).forEach(([collection, collectionData]) => { - // Skip if no changes for collection + + Object.entries(serviceData).forEach(([, collectionData]) => { if (collectionData === false) return - - // Process deletions (remove from store) + if (collectionData.deletions && collectionData.deletions.length > 0) { collectionData.deletions.forEach((identifier) => { - const key = identifierKey(provider, service, collection, identifier) - delete _entities.value[key] + delete _entities.value[identifier] }) } - - // Note: additions and modifications contain only identifiers - // The caller should fetch full entities using the fetch() method }) }) }) @@ -324,16 +163,156 @@ export const useEntitiesStore = defineStore('chronoEntitiesStore', () => { } } - // Return public API + /** + * Create a new empty entity object + */ + function fresh(): EntityObject { + return new EntityObject() + } + + /** + * Create a new entity with given collection identifier and properties + */ + async function create(target: CollectionIdentifier, properties: EntityPropertiesInterface): Promise { + transceiving.value = true + try { + const response = await entityService.create({ target, properties }) + + _entities.value[response.identifier] = response + + console.debug('[Chrono Manager][Store] - Successfully created entity:', response.identifier) + return response + } catch (error: any) { + console.error('[Chrono Manager][Store] - Failed to create entity:', error) + throw error + } finally { + transceiving.value = false + } + } + + /** + * Update an existing entity with given entity identifier and properties + */ + async function update(target: EntityIdentifier, properties: EntityPropertiesInterface): Promise { + transceiving.value = true + try { + const response = await entityService.update({ target, properties }) + + _entities.value[response.identifier] = response + + console.debug('[Chrono Manager][Store] - Successfully updated entity:', response.identifier) + return response + } catch (error: any) { + console.error('[Chrono Manager][Store] - Failed to update entity:', error) + throw error + } finally { + transceiving.value = false + } + } + + /** + * Delete entities by their identifiers. + */ + async function remove(targets: EntityIdentifier[]): Promise<{ successes: EntityIdentifier[], failures: EntityIdentifier[] }> { + transceiving.value = true + try { + const response = await entityService.delete({ targets }) + const successes: EntityIdentifier[] = [] + const failures: EntityIdentifier[] = [] + + Object.entries(response).forEach(([targetIdentifier, result]) => { + const originalIdentifier = targetIdentifier as EntityIdentifier + if (!result.disposition || result.disposition === 'error') { + console.warn(`[Chrono Manager][Store] - Entity delete on "${originalIdentifier}" returned an error: ${result.error})`) + failures.push(originalIdentifier) + return + } + + if (result.disposition !== 'moved' && result.disposition !== 'deleted') { + console.warn(`[Chrono Manager][Store] - Entity delete on "${originalIdentifier}" returned invalid disposition: ${result.disposition})`) + failures.push(originalIdentifier) + return + } + + const cachedEntity = _entities.value[originalIdentifier] + + if (result.disposition === 'moved' && cachedEntity && result.mutation) { + const movedEntity = cachedEntity.clone().fromJson({ + ...cachedEntity.toJson(), + collection: result.destination, + identifier: result.mutation, + }) + _entities.value[result.mutation] = movedEntity + } + + delete _entities.value[originalIdentifier] + successes.push(originalIdentifier) + }) + + console.debug('[Chrono Manager][Store] - Successfully deleted', successes.length, 'entities') + return { successes, failures } + } catch (error: any) { + console.error('[Chrono Manager][Store] - Failed to delete entities:', error) + throw error + } finally { + transceiving.value = false + } + } + + /** + * Move entities to another collection. + */ + async function move(target: CollectionIdentifier, sources: EntityIdentifier[]): Promise<{ successes: EntityIdentifier[], failures: EntityIdentifier[] }> { + transceiving.value = true + try { + const response = await entityService.move({ target, sources }) + const successes: EntityIdentifier[] = [] + const failures: EntityIdentifier[] = [] + + Object.entries(response).forEach(([sourceIdentifier, result]) => { + const originalIdentifier = sourceIdentifier as EntityIdentifier + if (!result.disposition || result.disposition === 'error') { + console.warn(`[Chrono Manager][Store] - Entity move on "${originalIdentifier}" returned an error: ${result.error})`) + failures.push(originalIdentifier) + return + } + + if (result.disposition !== 'moved') { + console.warn(`[Chrono Manager][Store] - Entity move on "${originalIdentifier}" returned invalid disposition: ${result.disposition})`) + failures.push(originalIdentifier) + return + } + + const cachedEntity = _entities.value[originalIdentifier] + if (cachedEntity && result.mutation) { + const movedEntity = cachedEntity.clone().fromJson({ + ...cachedEntity.toJson(), + collection: result.destination, + identifier: result.mutation, + }) + _entities.value[result.mutation] = movedEntity + delete _entities.value[originalIdentifier] + } + + successes.push(originalIdentifier) + }) + + console.debug('[Chrono Manager][Store] - Successfully moved', successes.length, 'entities') + return { successes, failures } + } catch (error: any) { + console.error('[Chrono Manager][Store] - Failed to move entities:', error) + throw error + } finally { + transceiving.value = false + } + } + return { - // State (readonly) transceiving: readonly(transceiving), - // Getters count, has, entities, entitiesForCollection, - // Actions entity, list, fetch, @@ -342,5 +321,7 @@ export const useEntitiesStore = defineStore('chronoEntitiesStore', () => { update, delete: remove, delta, + fresh, + move, } }) diff --git a/src/stores/providersStore.ts b/src/stores/providersStore.ts index a7141b6..a41a8c8 100644 --- a/src/stores/providersStore.ts +++ b/src/stores/providersStore.ts @@ -6,7 +6,7 @@ import { ref, computed, readonly } from 'vue' import { defineStore } from 'pinia' import { providerService } from '../services' import { ProviderObject } from '../models/provider' -import type { SourceSelector } from '../types' +import type { ProviderIdentifier } from '../types' export const useProvidersStore = defineStore('chronoProvidersStore', () => { // State @@ -54,10 +54,10 @@ export const useProvidersStore = defineStore('chronoProvidersStore', () => { * * @returns Promise with provider object list keyed by provider identifier */ - async function list(sources?: SourceSelector): Promise> { + async function list(targets?: ProviderIdentifier[]): Promise> { transceiving.value = true try { - const providers = await providerService.list({ sources }) + const providers = await providerService.list({ targets }) // Merge retrieved providers into state _providers.value = { ..._providers.value, ...providers } @@ -82,7 +82,7 @@ export const useProvidersStore = defineStore('chronoProvidersStore', () => { async function fetch(identifier: string): Promise { transceiving.value = true try { - const provider = await providerService.fetch({ identifier }) + const provider = await providerService.fetch({ target: identifier }) // Merge fetched provider into state _providers.value[provider.identifier] = provider @@ -104,10 +104,10 @@ export const useProvidersStore = defineStore('chronoProvidersStore', () => { * * @returns Promise with provider availability status */ - async function extant(sources: SourceSelector) { + async function extant(targets: ProviderIdentifier[]) { transceiving.value = true try { - const response = await providerService.extant({ sources }) + const response = await providerService.extant({ targets }) Object.entries(response).forEach(([providerId, providerStatus]) => { if (providerStatus === false) { @@ -115,7 +115,7 @@ export const useProvidersStore = defineStore('chronoProvidersStore', () => { } }) - console.debug('[Chrono Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'providers') + console.debug('[Chrono Manager][Store] - Successfully checked', targets ? targets.length : 0, 'providers') return response } catch (error: any) { console.error('[Chrono Manager][Store] - Failed to check providers:', error) diff --git a/src/stores/servicesStore.ts b/src/stores/servicesStore.ts index 23e2ae4..2d73f72 100644 --- a/src/stores/servicesStore.ts +++ b/src/stores/servicesStore.ts @@ -7,7 +7,8 @@ import { defineStore } from 'pinia' import { serviceService } from '../services' import { ServiceObject } from '../models/service' import type { - SourceSelector, + CollectionIdentifier, + ServiceIdentifier, ServiceInterface, } from '../types' @@ -55,20 +56,30 @@ export const useServicesStore = defineStore('chronoServicesStore', () => { * @returns Service object or null */ function service(provider: string, identifier: string | number, retrieve: boolean = false): ServiceObject | null { - const key = identifierKey(provider, identifier) - if (retrieve === true && !_services.value[key]) { - console.debug(`[Chrono Manager][Store] - Force fetching service "${key}"`) - fetch(provider, identifier) + return serviceByIdentifier(identifierKey(provider, identifier), retrieve) + } + + /** + * Get a service from store by its unique identifier, with optional retrieval + */ + function serviceByIdentifier(identifier: ServiceIdentifier, retrieve: boolean = false): ServiceObject | null { + if (retrieve === true && !_services.value[identifier]) { + console.debug(`[Chrono Manager][Store] - Force fetching service "${identifier}"`) + const separatorIndex = identifier.indexOf(':') + const provider = identifier.slice(0, separatorIndex) + const serviceIdentifier = identifier.slice(separatorIndex + 1) + + void fetch(provider, serviceIdentifier) } - - return _services.value[key] || null + + return _services.value[identifier] ?? null } /** * Unique key for a service */ - function identifierKey(provider: string, identifier: string | number | null): string { - return `${provider}:${identifier ?? ''}` + function identifierKey(provider: string, identifier: string | number | null): ServiceIdentifier { + return `${provider}:${identifier ?? ''}` as ServiceIdentifier } // Actions @@ -80,10 +91,10 @@ export const useServicesStore = defineStore('chronoServicesStore', () => { * * @returns Promise with service object list keyed by provider and service identifier */ - async function list(sources?: SourceSelector): Promise> { + async function list(targets?: ServiceIdentifier[] | CollectionIdentifier[]): Promise> { transceiving.value = true try { - const response = await serviceService.list({ sources }) + const response = await serviceService.list({ targets }) // Flatten nested structure: provider-id: { service-id: object } -> "provider-id:service-id": object const services: Record = {} @@ -141,12 +152,12 @@ export const useServicesStore = defineStore('chronoServicesStore', () => { * * @returns Promise with service availability status */ - async function extant(sources: SourceSelector) { + async function extant(targets: ServiceIdentifier[]) { transceiving.value = true try { - const response = await serviceService.extant({ sources }) + const response = await serviceService.extant({ targets }) - console.debug('[Chrono Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'services') + console.debug('[Chrono Manager][Store] - Successfully checked', targets?.length ?? 0, 'services') return response } catch (error: any) { console.error('[Chrono Manager][Store] - Failed to check services:', error) @@ -192,10 +203,14 @@ export const useServicesStore = defineStore('chronoServicesStore', () => { * * @returns Promise with updated service object */ - async function update(provider: string, identifier: string | number, data: Partial): Promise { + async function update(provider: string, identifier: string | number, delta: boolean, data: ServiceObject | Partial): Promise { transceiving.value = true try { - const service = await serviceService.update({ provider, identifier, data }) + const payload: Partial = data instanceof ServiceObject + ? (delta ? data.toJson(true) : data.toJson()) + : data + + const service = await serviceService.update({ provider, identifier, delta, data: payload }) // Merge updated service into state const key = identifierKey(service.provider, service.identifier) @@ -249,6 +264,7 @@ export const useServicesStore = defineStore('chronoServicesStore', () => { // Actions service, + serviceByIdentifier, list, fetch, extant, diff --git a/src/types/collection.ts b/src/types/collection.ts index 24f2896..9ce8a93 100644 --- a/src/types/collection.ts +++ b/src/types/collection.ts @@ -1,27 +1,37 @@ /** * Collection type definitions */ -import type { ListFilter, ListSort, SourceSelector } from './common'; +import type { + ServiceIdentifier, + CollectionIdentifier, + ListFilter, + ListSort +} from './common'; /** * Collection information */ -export interface CollectionInterface { +export interface CollectionInterface { + '@type': string; + version: number; provider: string; service: string | number; - collection: string | number | null; - identifier: string | number; + collection: CollectionIdentifier | null; + identifier: CollectionIdentifier; signature?: string | null; created?: string | null; modified?: string | null; - properties: CollectionPropertiesInterface; + properties: T; +} + +export interface CollectionModelInterface extends Omit, '@type' | 'version' | 'properties'> { + properties: CollectionPropertiesModelInterface; } export type CollectionContentTypes = 'event' | 'task' | 'journal'; export interface CollectionBaseProperties { '@type': string; - version: number; } export interface CollectionImmutableProperties extends CollectionBaseProperties { @@ -38,11 +48,13 @@ export interface CollectionMutableProperties extends CollectionBaseProperties { export interface CollectionPropertiesInterface extends CollectionMutableProperties, CollectionImmutableProperties {} +export interface CollectionPropertiesModelInterface extends Omit {} + /** * Collection list */ export interface CollectionListRequest { - sources?: SourceSelector; + sources?: ServiceIdentifier[] | CollectionIdentifier[]; filter?: ListFilter; sort?: ListSort; } @@ -59,18 +71,18 @@ export interface CollectionListResponse { * Collection fetch */ export interface CollectionFetchRequest { - provider: string; - service: string | number; - collection: string | number; + targets: CollectionIdentifier[]; } -export interface CollectionFetchResponse extends CollectionInterface {} +export interface CollectionFetchResponse { + [identifier: CollectionIdentifier]: CollectionInterface; +} /** * Collection extant */ export interface CollectionExtantRequest { - sources: SourceSelector; + targets: CollectionIdentifier[]; } export interface CollectionExtantResponse { @@ -87,7 +99,6 @@ export interface CollectionExtantResponse { export interface CollectionCreateRequest { provider: string; service: string | number; - collection?: string | number | null; // Parent Collection Identifier properties: CollectionMutableProperties; } @@ -97,9 +108,7 @@ export interface CollectionCreateResponse extends CollectionInterface {} * Collection modify */ export interface CollectionUpdateRequest { - provider: string; - service: string | number; - identifier: string | number; + target: CollectionIdentifier; properties: CollectionMutableProperties; } @@ -109,14 +118,13 @@ export interface CollectionUpdateResponse extends CollectionInterface {} * Collection delete */ export interface CollectionDeleteRequest { - provider: string; - service: string | number; - identifier: string | number; + target: CollectionIdentifier; options?: { force?: boolean; // Whether to force delete even if collection is not empty }; } export interface CollectionDeleteResponse { - success: boolean; + disposition: 'deleted' | 'moved'; + mutation?: CollectionInterface | null; // If moved, the new location of the collection } diff --git a/src/types/common.ts b/src/types/common.ts index ab912d4..210c391 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -44,34 +44,57 @@ export interface ApiErrorResponse { export type ApiResponse = ApiSuccessResponse | ApiErrorResponse; /** - * Selector for targeting specific providers, services, collections, or entities in list or extant operations. - * - * Example usage: - * { - * "provider1": true, // Select all services/collections/entities under provider1 - * "provider2": { - * "serviceA": true, // Select all collections/entities under serviceA of provider2 - * "serviceB": { - * "collectionX": true, // Select all entities under collectionX of serviceB of provider2 - * "collectionY": [1, 2, 3] // Select entities with identifiers 1, 2, and 3 under collectionY of serviceB of provider2 - * } - * } - * } + * Stream control start line. */ -export type SourceSelector = { - [provider: string]: boolean | ServiceSelector; -}; +export interface ApiStreamStartResponse { + type: 'control'; + status: 'start'; + version: number; + transaction: string; + total?: number; +} -export type ServiceSelector = { - [service: string]: boolean | CollectionSelector; -}; +/** + * Stream control end line + */ +export interface ApiStreamEndResponse { + type: 'control'; + status: 'end'; + total: number; +} -export type CollectionSelector = { - [collection: string | number]: boolean | EntitySelector; -}; +/** + * Stream error line + */ +export interface ApiStreamErrorResponse { + type: 'error'; + message: string; +} -export type EntitySelector = (string | number)[]; +export interface ApiStreamDataResponse { + type: 'data'; + data: T; +} +/** + * Shared stream control lines + */ +export type ApiStreamResponse = + | ApiStreamStartResponse + | ApiStreamEndResponse + | ApiStreamErrorResponse + | ApiStreamDataResponse; + +/** + * Identifiers for targeting specific providers, services, collections, or entities in list or extant operations. + * + * Operations accept flat arrays of colon-separated identifier strings, e.g. + * ["caldav:account1:calendar", "caldav:account1:calendar:1001"]. + */ +export type ProviderIdentifier = `${string}`; +export type ServiceIdentifier = `${string}:${string}`; +export type CollectionIdentifier = `${string}:${string}:${string | number}`; +export type EntityIdentifier = `${string}:${string}:${string}:${string | number}`; /** * Filter comparison for list operations diff --git a/src/types/entity.ts b/src/types/entity.ts index c113cfa..317ac1a 100644 --- a/src/types/entity.ts +++ b/src/types/entity.ts @@ -1,64 +1,85 @@ /** * Entity type definitions */ -import type { ListFilter, ListRange, ListSort, SourceSelector } from './common'; +import type { + CollectionIdentifier, + EntityIdentifier, + ListFilter, + ListRange, + ListSort, +} from './common'; import type { EventInterface } from './event'; import type { TaskInterface } from './task'; import type { JournalInterface } from './journal'; +export type EntityPropertiesInterface = EventInterface | TaskInterface | JournalInterface; + /** * Entity definition */ -export interface EntityInterface { +export interface EntityInterface { + '@type': string; + version: number; provider: string; service: string; - collection: string | number; - identifier: string | number; + collection: CollectionIdentifier; + identifier: EntityIdentifier; signature: string | null; created: string | null; modified: string | null; properties: T; } +export interface EntityModelInterface extends Omit, '@type' | 'version'> {} + /** - * Entity list + * Entity list bulk */ -export interface EntityListRequest { - sources?: SourceSelector; +export interface EntityListBulkRequest { + sources?: CollectionIdentifier[]; filter?: ListFilter; sort?: ListSort; range?: ListRange; } -export interface EntityListResponse { +export interface EntityListBulkResponse { [providerId: string]: { [serviceId: string]: { [collectionId: string]: { - [identifier: string]: EntityInterface; + [identifier: string]: EntityInterface; }; }; }; } +/** + * Entity list stream + */ +export interface EntityListStreamRequest { + sources?: CollectionIdentifier[]; + filter?: ListFilter; + sort?: ListSort; + range?: ListRange; +} + +export interface EntityListStreamResponse extends EntityInterface {} + /** * Entity fetch */ export interface EntityFetchRequest { - provider: string; - service: string | number; - collection: string | number; - identifiers: (string | number)[]; + targets: EntityIdentifier[]; } export interface EntityFetchResponse { - [identifier: string]: EntityInterface; + [identifier: string]: EntityInterface; } /** * Entity extant */ export interface EntityExtantRequest { - sources: SourceSelector; + targets: EntityIdentifier[]; } export interface EntityExtantResponse { @@ -71,50 +92,13 @@ export interface EntityExtantResponse { }; } -/** - * Entity create - */ -export interface EntityCreateRequest { - provider: string; - service: string | number; - collection: string | number; - properties: T; -} - -export interface EntityCreateResponse extends EntityInterface {} - -/** - * Entity update - */ -export interface EntityUpdateRequest { - provider: string; - service: string | number; - collection: string | number; - identifier: string | number; - properties: T; -} - -export interface EntityUpdateResponse extends EntityInterface {} - -/** - * Entity delete - */ -export interface EntityDeleteRequest { - provider: string; - service: string | number; - collection: string | number; - identifier: string | number; -} - -export interface EntityDeleteResponse { - success: boolean; -} - /** * Entity delta */ export interface EntityDeltaRequest { - sources: SourceSelector; + // Each target is provider:service:collection, or provider:service:collection:signature + // to request a delta relative to a known signature (the signature is the entity slot). + targets: (CollectionIdentifier | EntityIdentifier)[]; } export interface EntityDeltaResponse { @@ -128,4 +112,58 @@ export interface EntityDeltaResponse { }; }; }; -} \ No newline at end of file +} + +/** + * Entity create + */ +export interface EntityCreateRequest { + target: CollectionIdentifier; + properties: T; + options?: Record; +} + +export interface EntityCreateResponse extends EntityInterface {} + +/** + * Entity update + */ +export interface EntityUpdateRequest { + target: EntityIdentifier; + properties: T; +} + +export interface EntityUpdateResponse extends EntityInterface {} + +/** + * Entity delete + */ +export interface EntityDeleteRequest { + targets: EntityIdentifier[]; +} + +export interface EntityDeleteResponse { + [targetIdentifier: EntityIdentifier]: { + disposition: 'deleted' | 'moved' | 'error'; + destination: CollectionIdentifier | null; + mutation: EntityIdentifier | null; + error?: string; + }; +} + +/** + * Entity move + */ +export interface EntityMoveRequest { + target: CollectionIdentifier; + sources: EntityIdentifier[]; +} + +export interface EntityMoveResponse { + [sourceIdentifier: EntityIdentifier]: { + disposition: 'moved' | 'error'; + destination: CollectionIdentifier | null; + mutation: EntityIdentifier | null; + error?: string; + }; +} diff --git a/src/types/provider.ts b/src/types/provider.ts index b9632c8..1b39b6d 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -1,7 +1,7 @@ /** * Provider type definitions */ -import type { SourceSelector } from "./common"; +import type { ProviderIdentifier } from './common'; /** * Provider capabilities @@ -28,22 +28,24 @@ export interface ProviderInterface { capabilities: ProviderCapabilitiesInterface; } +export interface ProviderModelInterface extends Omit {} + /** * Provider list */ export interface ProviderListRequest { - sources?: SourceSelector; + targets?: ProviderIdentifier[]; } export interface ProviderListResponse { - [identifier: string]: ProviderInterface; + [identifier: ProviderIdentifier]: ProviderInterface; } /** * Provider fetch */ export interface ProviderFetchRequest { - identifier: string; + target: ProviderIdentifier; } export interface ProviderFetchResponse extends ProviderInterface {} @@ -52,9 +54,9 @@ export interface ProviderFetchResponse extends ProviderInterface {} * Provider extant */ export interface ProviderExtantRequest { - sources: SourceSelector; + targets: ProviderIdentifier[]; } export interface ProviderExtantResponse { - [identifier: string]: boolean; + [identifier: ProviderIdentifier]: boolean; } diff --git a/src/types/service.ts b/src/types/service.ts index ce47898..3c791e9 100644 --- a/src/types/service.ts +++ b/src/types/service.ts @@ -1,7 +1,13 @@ /** * Service type definitions */ -import type { SourceSelector, ListFilterComparisonOperator } from './common'; +import type { + ServiceIdentifier, + CollectionIdentifier, + ListFilterComparisonOperator, +} from './common'; +import type { Identity } from '@/models/identity'; +import type { Location } from '@/models/location'; /** * Service capabilities @@ -47,11 +53,18 @@ export interface ServiceInterface { auxiliary?: Record; // Provider-specific extension data } +export interface ServiceModelInterface extends Omit<{ + [K in keyof ServiceInterface]-?: Exclude; +}, '@type' | 'location' | 'identity'> { + location: Location | null; + identity: Identity | null; +} + /** * Service list */ export interface ServiceListRequest { - sources?: SourceSelector; + targets?: ServiceIdentifier[] | CollectionIdentifier[]; } export interface ServiceListResponse { @@ -74,7 +87,7 @@ export interface ServiceFetchResponse extends ServiceInterface {} * Service extant */ export interface ServiceExtantRequest { - sources: SourceSelector; + targets: ServiceIdentifier[]; } export interface ServiceExtantResponse { @@ -99,6 +112,7 @@ export interface ServiceCreateResponse extends ServiceInterface {} export interface ServiceUpdateRequest { provider: string; identifier: string | number; + delta?: boolean; data: Partial; }