From c4cb1512d2e4de2a3e18228b852b84cbca3b9c7f Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Sat, 20 Jun 2026 18:56:35 -0400 Subject: [PATCH] refactor: front end code to unified manager api Signed-off-by: Sebastian Krupinski --- src/models/clone-plain.ts | 27 ++ src/models/collection.ts | 115 ++++---- src/models/entity.ts | 96 +++---- src/models/identity.ts | 292 ++++++++++++++------ src/models/index.ts | 21 +- src/models/location.ts | 292 +++++++++----------- src/models/mutation-proxy.ts | 61 +++++ src/models/provider.ts | 23 +- src/models/service.ts | 129 ++++++--- src/services/collectionService.ts | 21 +- src/services/entityService.ts | 111 +++++--- src/services/transceive.ts | 96 ++++++- src/stores/collectionsStore.ts | 238 ++++++++-------- src/stores/entitiesStore.ts | 438 +++++++++++++++++------------- src/stores/providersStore.ts | 14 +- src/stores/servicesStore.ts | 121 +++++---- src/types/collection.ts | 48 ++-- src/types/common.ts | 68 +++-- src/types/entity.ts | 150 ++++++---- src/types/provider.ts | 15 +- src/types/service.ts | 35 ++- 21 files changed, 1484 insertions(+), 927 deletions(-) create mode 100644 src/models/clone-plain.ts create mode 100644 src/models/mutation-proxy.ts 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 3c92203..f8195ab 100644 --- a/src/models/collection.ts +++ b/src/models/collection.ts @@ -2,46 +2,55 @@ * Class model for Collection Interface */ -import type { CollectionContentTypes, CollectionInterface, CollectionPropertiesInterface } from "@/types/collection"; +import type { + CollectionContentTypes, + CollectionInterface, + CollectionModelInterface, + CollectionPropertiesInterface, + CollectionPropertiesModelInterface +} from "@/types/collection"; +import { clonePlain } from './clone-plain'; +import type { + CollectionIdentifier, + ServiceIdentifier +} from "@/types/common"; -export class CollectionObject implements CollectionInterface { +export class CollectionObject implements CollectionModelInterface { - _data!: CollectionInterface; + _data!: CollectionInterface; + _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: { '@type': 'people:addressbook', content: [], label: '', description: null, rank: null, visibility: null, color: null }, }; } 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 +59,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 { @@ -75,36 +84,30 @@ export class CollectionObject implements CollectionInterface { } 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 { +export class CollectionPropertiesObject implements CollectionPropertiesModelInterface { - _data!: CollectionPropertiesInterface; + private _data!: CollectionPropertiesInterface; constructor() { this._data = { - '@type': 'people:collection', - version: 1, + '@type': 'people:addressbook', content: [], label: '', description: null, @@ -115,32 +118,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 +178,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 69c1339..6231853 100644 --- a/src/models/entity.ts +++ b/src/models/entity.ts @@ -2,64 +2,59 @@ * Class model for Entity Interface */ -import type { EntityInterface } from "@/types/entity"; +import type { EntityInterface, EntityModelInterface, EntityPropertiesInterface } from "@/types/entity"; import type { IndividualInterface } from "@/types/individual"; import type { OrganizationInterface } from "@/types/organization"; import type { GroupInterface } from "@/types/group"; +import type { CollectionIdentifier, EntityIdentifier } from "@/types/common"; import { IndividualObject } from "./individual"; import { OrganizationObject } from "./organization"; import { GroupObject } from "./group"; +import { clonePlain } from './clone-plain'; -export class EntityObject implements EntityInterface { +export class EntityObject implements EntityModelInterface { + + private _data!: EntityInterface; + private _properties: IndividualObject | OrganizationObject | GroupObject | undefined = undefined; - _data!: EntityInterface; - constructor() { this._data = { + '@type': 'people:entity', + version: 1, provider: '', service: '', - collection: '', - identifier: '', + collection: '' as CollectionIdentifier, + identifier: '' as EntityIdentifier, signature: null, created: null, modified: null, - properties: new IndividualObject(), + properties: new IndividualObject().toJson(), }; } - - fromJson(data: EntityInterface) : EntityObject { - this._data = data; - if (data.properties) { - const type = data.properties.type; - if (type === 'organization') { - this._data.properties = new OrganizationObject().fromJson(data.properties as OrganizationInterface); - } else if (type === 'group') { - this._data.properties = new GroupObject().fromJson(data.properties as GroupInterface); - } else { - this._data.properties = new IndividualObject().fromJson(data.properties as IndividualInterface); - } - } + + fromJson(data: EntityInterface): EntityObject { + this._data = clonePlain(data); + this._properties = undefined; return this; } - + toJson(): EntityInterface { - const json = { ...this._data }; - if (this._data.properties instanceof IndividualObject || - this._data.properties instanceof OrganizationObject || - this._data.properties instanceof GroupObject) { - json.properties = this._data.properties.toJson(); - } - return json; + 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; } @@ -68,11 +63,11 @@ export class EntityObject implements EntityInterface { return this._data.service; } - get collection(): string | number { + get collection(): CollectionIdentifier { return this._data.collection; } - get identifier(): string | number { + get identifier(): EntityIdentifier { return this._data.identifier; } @@ -88,20 +83,25 @@ export class EntityObject implements EntityInterface { return this._data.modified; } + /** Entity Properties (individual | organization | group) */ + get properties(): IndividualObject | OrganizationObject | GroupObject { - if (this._data.properties instanceof IndividualObject || - this._data.properties instanceof OrganizationObject || - this._data.properties instanceof GroupObject) { - return this._data.properties; + if (this._properties) { + return this._properties; } - const defaultProperties = new IndividualObject(); - this._data.properties = defaultProperties; - return defaultProperties; + const raw = this._data.properties as EntityPropertiesInterface; + const type = (raw as { type?: string })?.type; + + if (type === 'organization') { + this._properties = new OrganizationObject().fromJson(raw as OrganizationInterface); + } else if (type === 'group') { + this._properties = new GroupObject().fromJson(raw as GroupInterface); + } else { + this._properties = new IndividualObject().fromJson(raw as IndividualInterface); + } + + return this._properties; } - set properties(value: IndividualObject | OrganizationObject | GroupObject) { - this._data.properties = value; - } - -} \ No newline at end of file +} diff --git a/src/models/identity.ts b/src/models/identity.ts index 3291090..7ec5a22 100644 --- a/src/models/identity.ts +++ b/src/models/identity.ts @@ -1,5 +1,5 @@ /** - * Identity implementation classes for Mail Manager services + * Identity implementation classes for People Manager services */ import type { @@ -10,13 +10,55 @@ import type { ServiceIdentityOAuth, ServiceIdentityCertificate } from '@/types/service'; +import { MutationProxy } from './mutation-proxy'; +import { clonePlain } from './clone-plain'; /** * Base Identity class */ -export abstract class Identity { - abstract toJson(): ServiceIdentity; - +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 { + if (delta) { + return clonePlain(this._mutated); + } + + return { + ...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': @@ -38,81 +80,109 @@ export abstract class Identity { /** * No authentication */ -export class IdentityNone extends Identity { - readonly type = 'NA' as const; +export class IdentityNone extends Identity { + + constructor() { + super({ + type: 'NA' + }); + } static fromJson(_data: ServiceIdentityNone): IdentityNone { return new IdentityNone(); } - toJson(): ServiceIdentityNone { - return { - type: this.type - }; + 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; +export class IdentityBasic extends Identity { constructor(identity: string = '', secret: string = '') { - super(); - this.identity = identity; - this.secret = secret; + super({ + type: 'BA', + identity, + secret + }); } static fromJson(data: ServiceIdentityBasic): IdentityBasic { - return new IdentityBasic(data.identity, data.secret); + return new IdentityBasic().load(data); } - toJson(): ServiceIdentityBasic { - return { - type: this.type, - identity: this.identity, - secret: this.secret - }; + 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; +export class IdentityToken extends Identity { constructor(token: string = '') { - super(); - this.token = token; + super({ + type: 'TA', + token + }); } static fromJson(data: ServiceIdentityToken): IdentityToken { - return new IdentityToken(data.token); + return new IdentityToken().load(data); } - toJson(): ServiceIdentityToken { - return { - type: this.type, - token: this.token - }; + 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 = '', @@ -121,33 +191,22 @@ export class IdentityOAuth extends Identity { refreshToken?: string, refreshLocation?: string ) { - super(); - this.accessToken = accessToken; - this.accessScope = accessScope; - this.accessExpiry = accessExpiry; - this.refreshToken = refreshToken; - this.refreshLocation = refreshLocation; + super({ + type: 'OA', + accessToken, + accessScope, + accessExpiry, + refreshToken, + refreshLocation + }); } static fromJson(data: ServiceIdentityOAuth): IdentityOAuth { - return new IdentityOAuth( - data.accessToken, - data.accessScope, - data.accessExpiry, - data.refreshToken, - data.refreshLocation - ); + return new IdentityOAuth().load(data); } - 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 }) - }; + clone(): IdentityOAuth { + return IdentityOAuth.fromJson(this.toJson()); } isExpired(): boolean { @@ -159,38 +218,101 @@ export class IdentityOAuth extends Identity { 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(); - this.certificate = certificate; - this.privateKey = privateKey; - this.passphrase = passphrase; + super({ + type: 'CC', + certificate, + privateKey, + passphrase + }); } static fromJson(data: ServiceIdentityCertificate): IdentityCertificate { - return new IdentityCertificate( - data.certificate, - data.privateKey, - data.passphrase - ); + return new IdentityCertificate().load(data); } - toJson(): ServiceIdentityCertificate { - return { - type: this.type, - certificate: this.certificate, - privateKey: this.privateKey, - ...(this.passphrase && { passphrase: this.passphrase }) - }; + 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 771d251..1661bc2 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,7 +1,26 @@ export { ProviderObject } from './provider'; export { ServiceObject } from './service'; -export { CollectionObject } from './collection'; +export { + CollectionObject, + CollectionPropertiesObject +} from './collection'; export { EntityObject } from './entity'; export { GroupObject } from './group'; export { IndividualObject } from './individual'; export { OrganizationObject } from './organization'; +export { + Identity, + IdentityNone, + IdentityBasic, + IdentityToken, + IdentityOAuth, + IdentityCertificate +} from './identity'; +export { + Location, + LocationUri, + LocationFile +} from './location'; +export { + MutationProxy +} from './mutation-proxy'; diff --git a/src/models/location.ts b/src/models/location.ts index 7e19cce..5064b3d 100644 --- a/src/models/location.ts +++ b/src/models/location.ts @@ -1,29 +1,61 @@ /** - * Location implementation classes for Mail Manager services + * Location implementation classes for People Manager services */ import type { ServiceLocation, ServiceLocationUri, - ServiceLocationSocketSole, - ServiceLocationSocketSplit, ServiceLocationFile } from '@/types/service'; +import { MutationProxy } from './mutation-proxy'; +import { clonePlain } from './clone-plain'; /** * Base Location class */ -export abstract class Location { - abstract toJson(): ServiceLocation; - +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 { + if (delta) { + return clonePlain(this._mutated); + } + + return { + ...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 'SOCKET_SOLE': - return LocationSocketSole.fromJson(data); - case 'SOCKET_SPLIT': - return LocationSocketSplit.fromJson(data); case 'FILE': return LocationFile.fromJson(data); default: @@ -34,16 +66,9 @@ export abstract class Location { /** * URI-based service location for API and web services - * Used by: JMAP, Gmail API, etc. + * Used by: CardDAV, Google People 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', @@ -53,188 +78,115 @@ export class LocationUri extends Location { 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; + super({ + type: 'URI', + scheme, + host, + port, + ...(path !== undefined && { path }), + verifyPeer, + verifyHost, + }); } 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 }) - }; + return new LocationUri().load(data); } getUrl(): string { const path = this.path || ''; return `${this.scheme}://${this.host}:${this.port}${path}`; } -} -/** - * 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; - - 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; + clone(): LocationUri { + return LocationUri.fromJson(structuredClone(this.toJson())); } - static fromJson(data: ServiceLocationSocketSole): LocationSocketSole { - return new LocationSocketSole( - data.host, - data.port, - data.encryption, - data.verifyPeer ?? true, - data.verifyHost ?? true - ); + get type(): 'URI' { + return this._data.type; } - 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; + get scheme(): string { + return this._data.scheme; } - 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 - ); + set scheme(value: string) { + this._data.scheme = value; } - 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 }) - }; + 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; + } + } /** * File-based service location * Used by: local file system providers */ -export class LocationFile extends Location { - readonly type = 'FILE' as const; - path: string; +export class LocationFile extends Location { constructor(path: string = '') { - super(); - this.path = path; + super({ + type: 'FILE', + path, + }); } static fromJson(data: ServiceLocationFile): LocationFile { - return new LocationFile(data.path); + return new LocationFile().load(data); } - toJson(): ServiceLocationFile { - return { - type: this.type, - path: this.path - }; + clone(): LocationFile { + return LocationFile.fromJson(structuredClone(this.toJson())); } + + 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..4a72b54 --- /dev/null +++ b/src/models/mutation-proxy.ts @@ -0,0 +1,61 @@ +import { clonePlain } from './clone-plain'; + +export class MutationProxy { + + private readonly getOriginal: () => T; + private readonly getMutated: () => Partial; + + constructor( + getOriginal: () => T, + getMutated: () => Partial, + ) { + this.getOriginal = getOriginal; + this.getMutated = getMutated; + } + + 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(); + const original = this.getOriginal(); + return key in mutated ? mutated[key] : original[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; + } + + const mutated = this.getMutated(); + const original = this.getOriginal(); + return prop in mutated || prop in original; + }, + ownKeys: () => { + const mutated = this.getMutated(); + const original = this.getOriginal(); + + return Array.from(new Set([ + ...Reflect.ownKeys(original), + ...Reflect.ownKeys(mutated), + ])); + }, + getOwnPropertyDescriptor: () => ({ + enumerable: true, + configurable: true, + }), + }); + } + +} diff --git a/src/models/provider.ts b/src/models/provider.ts index 2b1b388..7bbd770 100644 --- a/src/models/provider.ts +++ b/src/models/provider.ts @@ -2,18 +2,21 @@ * Class model for Provider Interface */ -import type { +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; constructor() { this._data = { '@type': 'people:provider', + version: 1, identifier: '', label: '', capabilities: {}, @@ -21,12 +24,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 +50,6 @@ export class ProviderObject implements ProviderInterface { /** Immutable Properties */ - get '@type'(): string { - return this._data['@type']; - } - get identifier(): string { return this._data.identifier; } @@ -56,7 +59,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 3f1934f..b4c2140 100644 --- a/src/models/service.ts +++ b/src/models/service.ts @@ -5,34 +5,85 @@ 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; + _location: Location | null | undefined = undefined; + _identity: Identity | null | undefined = undefined; constructor() { - this._data = { + this._original = { '@type': 'people:service', + version: 1, provider: '', identifier: null, label: null, 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; return this; } - toJson(): ServiceInterface { - return this._data; + toJson(): ServiceInterface; + toJson(delta: true): Partial; + toJson(delta?: boolean): ServiceInterface | Partial { + if (delta) { + const json: Partial = clonePlain(this._mutated); + + if (this._location?.mutated()) { + json.location = this._location.toJson(true) as ServiceInterface['location']; + } + + 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._location?.mutated() ?? false) || (this._identity?.mutated() ?? false); } capable(capability: keyof ServiceCapabilitiesInterface): boolean { @@ -49,10 +100,6 @@ export class ServiceObject implements ServiceInterface { /** Immutable Properties */ - get '@type'(): string { - return this._data['@type']; - } - get provider(): string { return this._data.provider; } @@ -61,8 +108,8 @@ export class ServiceObject implements ServiceInterface { return this._data.identifier; } - get capabilities(): ServiceCapabilitiesInterface | undefined { - return this._data.capabilities; + get capabilities(): ServiceCapabilitiesInterface { + return this._data.capabilities ?? {}; } /** Mutable Properties */ @@ -83,46 +130,48 @@ export class ServiceObject implements ServiceInterface { this._data.enabled = value; } - get location(): ServiceLocation | null { - return this._data.location ?? null; + get location(): Location | null { + if (this._location !== undefined) { + return this._location; + } + + if (this._data.location) { + this._location = Location.fromJson(this._data.location as ServiceLocation); + return this._location; + } + + this._location = null; + return null; } - set location(value: ServiceLocation | null) { - this._data.location = value; + set location(value: Location | null) { + this._location = value; } - get identity(): ServiceIdentity | null { - return this._data.identity ?? null; + get identity(): Identity | null { + if (this._identity !== undefined) { + return this._identity; + } + + if (this._data.identity) { + this._identity = Identity.fromJson(this._data.identity); + return this._identity; + } + + this._identity = null; + return null; } - set identity(value: ServiceIdentity | null) { - this._data.identity = value; + set identity(value: Identity | null) { + this._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); - } - } diff --git a/src/services/collectionService.ts b/src/services/collectionService.ts index 1ab0fed..a394913 100644 --- a/src/services/collectionService.ts +++ b/src/services/collectionService.ts @@ -70,9 +70,16 @@ 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); + + // Convert response to CollectionObject instances + const list: Record = {}; + Object.entries(response).forEach(([, collection]) => { + list[collection.identifier] = createCollectionObject(collection); + }); + + return list; }, /** @@ -123,8 +130,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 92d75dc..c47442f 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,10 @@ import type { EntityDeleteResponse, EntityDeltaRequest, EntityDeltaResponse, + EntityMoveRequest, + EntityMoveResponse, + EntityCopyRequest, + EntityCopyResponse, EntityInterface, } from '../types/entity'; import { useIntegrationStore } from '@KTXC/stores/integrationStore'; @@ -31,7 +37,7 @@ function createEntityObject(data: EntityInterface): EntityObject { const integrationStore = useIntegrationStore(); const factoryItem = integrationStore.getItemById('people_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,15 +45,15 @@ function createEntityObject(data: EntityInterface): EntityObject { export const entityService = { /** - * Retrieve list of entities, optionally filtered by source selector - * + * Retrieve list of entities, optionally filtered by source collection identifiers + * * @param request - list request parameters - * + * * @returns Promise with entity object list grouped by provider, service, collection, and entity identifier */ - async list(request: EntityListRequest = {}): Promise>>>> { - const response = await transceivePost('entity.list', request); - + async listBulk(request: EntityListBulkRequest = {}): Promise>>>> { + const response = await transceivePost('entity.listBulk', request); + // Convert nested response to EntityObject instances const providerList: Record>>> = {}; Object.entries(response).forEach(([providerId, providerServices]) => { @@ -65,34 +71,55 @@ export const entityService = { }); providerList[providerId] = serviceList; }); - + return providerList; }, + /** + * Stream entities as NDJSON, invoking onEntity for each entity as it arrives. + * + * The server emits one entity per line so the caller receives entities + * progressively rather than waiting for the full collection to load. + * + * @param request - stream request parameters (same shape as list) + * @param onEntity - called synchronously for each entity as it is received + * + * @returns Promise resolving to { total } when the stream completes + */ + async listStream(request: EntityListStreamRequest, onEntity: (entity: EntityObject) => void): Promise<{ total: number }> { + return await transceiveStream( + 'entity.listStream', + request, + (entity) => { + onEntity(createEntityObject(entity)); + } + ); + }, + /** * Retrieve a specific entity by provider and identifier - * + * * @param request - fetch request parameters - * + * * @returns Promise with entity objects keyed by identifier */ 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 - * + * Retrieve entity availability status for a given set of entity identifiers + * * @param request - extant request parameters - * + * * @returns Promise with entity availability status */ async extant(request: EntityExtantRequest): Promise { @@ -101,9 +128,9 @@ export const entityService = { /** * Create a new entity - * + * * @param request - create request parameters - * + * * @returns Promise with created entity object */ async create(request: EntityCreateRequest): Promise { @@ -113,9 +140,9 @@ export const entityService = { /** * Update an existing entity - * + * * @param request - update request parameters - * + * * @returns Promise with updated entity object */ async update(request: EntityUpdateRequest): Promise { @@ -124,11 +151,11 @@ export const entityService = { }, /** - * Delete an entity - * + * Delete entities by their identifiers + * * @param request - delete request parameters - * - * @returns Promise with deletion result + * + * @returns Promise with deletion results keyed by source entity identifier */ async delete(request: EntityDeleteRequest): Promise { return await transceivePost('entity.delete', request); @@ -136,15 +163,37 @@ export const entityService = { /** * Retrieve delta changes for entities - * + * * @param request - delta request parameters - * - * @returns Promise with delta changes (created, modified, deleted) + * + * @returns Promise with delta changes (additions, modifications, deletions) */ async delta(request: EntityDeltaRequest): Promise { return await transceivePost('entity.delta', request); }, + /** + * Move entities to a target collection + * + * @param request - move request parameters + * + * @returns Promise with move results keyed by source entity identifier + */ + async move(request: EntityMoveRequest): Promise { + return await transceivePost('entity.move', request); + }, + + /** + * Copy entities to a target collection + * + * @param request - copy request parameters + * + * @returns Promise with copy results keyed by source entity identifier + */ + async copy(request: EntityCopyRequest): Promise { + return await transceivePost('entity.copy', request); + }, + }; export default entityService; diff --git a/src/services/transceive.ts b/src/services/transceive.ts index fa2a67a..53d2aec 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/people_manager/v1'; @@ -45,6 +45,98 @@ export async function transceivePost( const errorMessage = `[${operation}] ${response.data.message}${response.data.code ? ` (code: ${response.data.code})` : ''}`; throw new Error(errorMessage); } - + return response.data; } + +/** + * Stream an NDJSON API response, unwrapping data frames for the caller. + * + * The server emits one JSON object per line with a transport-level `type` + * discriminant. This helper consumes control and error frames, forwards only + * unwrapped `data` payloads to the caller, and returns the final stream total. + * + * @param operation - Operation name, e.g. 'entity.listStream' + * @param data - Operation-specific request data + * @param onData - Synchronous callback invoked for every unwrapped data payload. + * May throw to abort the stream. + * @param user - Optional user identifier override + * @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, + user?: string +): Promise<{ total: number }> { + const request: ApiRequest = { + version: API_VERSION, + transaction: generateTransactionId(), + operation, + data, + user, + }; + + let total = 0; + + 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()!; // retain any incomplete trailing chunk + + for (const line of lines) { + if (!line.trim()) continue; + const message = JSON.parse(line) as ApiStreamResponse; + + if (message.type === 'control') { + if (message.status === 'end') { + total = message.total; + } + continue; + } + + if (message.type === 'error') { + throw new Error(`[${operation}] ${message.message}`); + } + + onData(message.data); + } + } + + // flush any remaining bytes still in the buffer + if (buffer.trim()) { + const message = JSON.parse(buffer) as ApiStreamResponse; + + if (message.type === 'control') { + if (message.status === 'end') { + total = message.total; + } + } else if (message.type === 'error') { + throw new Error(`[${operation}] ${message.message}`); + } else { + onData(message.data); + } + } + } finally { + reader.releaseLock(); + } + }, + }); + + return { total }; +} diff --git a/src/stores/collectionsStore.ts b/src/stores/collectionsStore.ts index d39e9a2..3f02824 100644 --- a/src/stores/collectionsStore.ts +++ b/src/stores/collectionsStore.ts @@ -4,11 +4,17 @@ 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('peopleCollectionsStore', () => { + // State const _collections = ref>({}) const transceiving = ref(false) @@ -33,85 +39,65 @@ export const useCollectionsStore = defineStore('peopleCollectionsStore', () => { */ 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 serviceKey = String(collection.service) + 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 target - 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(`[People 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(`[People 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 => String(collection.service) === serviceIdentifier) + if (retrieve === true && serviceCollections.length === 0) { - console.debug(`[People Manager][Store] - Force fetching collections for service "${provider}:${service}"`) - const sources: SourceSelector = { - [provider]: { - [String(service)]: true - } - } - list(sources) + console.debug(`[People Manager][Store] - Force fetching collections for service "${serviceIdentifier}"`) + list([serviceIdentifier]) } - + return serviceCollections } /** - * Create unique key for a collection - */ - 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 + * Retrieve all or specific collections, optionally filtered by service/collection identifiers + * + * @param sources - optional service/collection identifiers * @param filter - optional list filter * @param sort - optional list sort - * - * @returns Promise with collection object list keyed by provider, service, and collection identifier + * + * @returns Promise with collection object list keyed by 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 }) @@ -121,8 +107,7 @@ export const useCollectionsStore = defineStore('peopleCollectionsStore', () => { 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 }) }) }) @@ -139,29 +124,28 @@ export const useCollectionsStore = defineStore('peopleCollectionsStore', () => { 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 + * + * @param targets - collection identifiers to fetch + * + * @returns Promise with collection objects keyed by identifier */ - 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('[People Manager][Store] - Successfully fetched collection:', key) + // Merge fetched collections into state + Object.values(response).forEach(collectionObj => { + _collections.value[collectionObj.identifier] = collectionObj + }) + + console.debug('[People Manager][Store] - Successfully fetched collections:', Object.keys(response).join(', ')) return response } catch (error: any) { - console.error('[People Manager][Store] - Failed to fetch collection:', error) + console.error('[People Manager][Store] - Failed to fetch collections:', error) throw error } finally { transceiving.value = false @@ -169,18 +153,18 @@ export const useCollectionsStore = defineStore('peopleCollectionsStore', () => { } /** - * Retrieve collection availability status for a given source selector - * - * @param sources - source selector to check availability for - * + * Retrieve collection availability status for the given collection identifiers + * + * @param targets - collection identifiers to check availability for + * * @returns Promise with collection availability status */ - async function extant(sources: SourceSelector) { + async function extant(targets: CollectionIdentifier[]): Promise>>> { transceiving.value = true try { - const response = await collectionService.extant({ sources }) - - console.debug('[People Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'collections') + const response = await collectionService.extant({ targets }) + + console.debug('[People Manager][Store] - Successfully checked', targets ? targets.length : 0, 'collections') return response } catch (error: any) { console.error('[People Manager][Store] - Failed to check collections:', error) @@ -191,30 +175,28 @@ export const useCollectionsStore = defineStore('peopleCollectionsStore', () => { } /** - * Create a new collection with given provider, service, and data - * + * Create a new collection with given provider, service, and properties + * * @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 - * + * @param properties - collection properties for creation + * * @returns Promise with created collection object */ - 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('[People Manager][Store] - Successfully created collection:', key) + + if (response instanceof CollectionObject) { + _collections.value[response.identifier] = response + } + + console.debug('[People Manager][Store] - Successfully created collection:', response.identifier) return response } catch (error: any) { console.error('[People Manager][Store] - Failed to create collection:', error) @@ -225,30 +207,26 @@ export const useCollectionsStore = defineStore('peopleCollectionsStore', () => { } /** - * 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 - * + * Update an existing collection with given target and properties + * + * @param target - collection identifier for the collection to update + * @param properties - collection properties for update + * * @returns Promise with updated collection object */ - 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('[People Manager][Store] - Successfully updated collection:', key) + + if (response instanceof CollectionObject) { + _collections.value[response.identifier] = response + } + + console.debug('[People Manager][Store] - Successfully updated collection:', response.identifier) return response } catch (error: any) { console.error('[People Manager][Store] - Failed to update collection:', error) @@ -259,24 +237,34 @@ export const useCollectionsStore = defineStore('peopleCollectionsStore', () => { } /** - * 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 - * + * Delete a collection by identifier, with optional force delete if collection is not empty. + * + * @param target - collection identifier for the collection to delete + * @param force - optional flag to force delete if collection is not empty + * * @returns Promise with deletion result */ - 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('[People Manager][Store] - Successfully deleted collection:', key) + if (response !== true && !(response instanceof CollectionObject)) { + console.warn('[People 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('[People Manager][Store] - Successfully moved collection to trash', target, '->', response.identifier) + return response + } + + console.debug('[People Manager][Store] - Successfully deleted collection:', target) + return response } catch (error: any) { console.error('[People Manager][Store] - Failed to delete collection:', error) throw error diff --git a/src/stores/entitiesStore.ts b/src/stores/entitiesStore.ts index 17cbc10..50ca3d0 100644 --- a/src/stores/entitiesStore.ts +++ b/src/stores/entitiesStore.ts @@ -5,8 +5,15 @@ 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 { EntityObject, GroupObject, IndividualObject, OrganizationObject } from '../models' +import type { + CollectionIdentifier, + EntityIdentifier, + ListFilter, + ListRange, + ListSort, +} from '../types/common' +import type { EntityPropertiesInterface } from '@/types/entity' export const useEntitiesStore = defineStore('peopleEntitiesStore', () => { // State @@ -30,95 +37,63 @@ export const useEntitiesStore = defineStore('peopleEntitiesStore', () => { /** * 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 target - 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(`[People 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(`[People 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 target - 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(`[People Manager][Store] - Force fetching entities for collection "${provider}:${service}:${collection}"`) - const sources: SourceSelector = { - [provider]: { - [String(service)]: { - [String(collection)]: true - } - } - } - list(sources) + console.debug(`[People Manager][Store] - Force fetching entities for collection "${target}"`) + list([target]) } - + return collectionEntities } - /** - * Create unique key for an entity - */ - 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 + * Retrieve all or specific entities, optionally filtered by source collection identifiers + * + * @param sources - collection identifiers to stream entities from * @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('[People Manager][Store] - Successfully retrieved', Object.keys(entities).length, 'entities') return entities @@ -129,28 +104,24 @@ export const useEntitiesStore = defineStore('peopleEntitiesStore', () => { 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 - * + * Retrieve specific entities by their identifiers + * + * @param targets - array of entity identifiers to fetch + * * @returns Promise with entity objects keyed by identifier */ - 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 }) - + const response = await entityService.fetch({ targets }) + // Merge fetched entities into state 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('[People Manager][Store] - Successfully fetched', Object.keys(entities).length, 'entities') @@ -164,16 +135,16 @@ export const useEntitiesStore = defineStore('peopleEntitiesStore', () => { } /** - * Retrieve entity availability status for a given source selector - * - * @param sources - source selector to check availability for - * + * Retrieve entity availability status for a given set of entity identifiers + * + * @param targets - array of entity identifiers to check availability for + * * @returns Promise with entity availability status */ - 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('[People Manager][Store] - Successfully checked entity availability') return response } catch (error: any) { @@ -184,130 +155,43 @@ export const useEntitiesStore = defineStore('peopleEntitiesStore', () => { } } - /** - * 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('[People Manager][Store] - Successfully created entity:', key) - return response - } catch (error: any) { - console.error('[People 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('[People Manager][Store] - Successfully updated entity:', key) - return response - } catch (error: any) { - console.error('[People 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('[People Manager][Store] - Successfully deleted entity:', key) - return response - } catch (error: any) { - console.error('[People 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 - * + * + * @param targets - collection identifiers (provider:service:collection), optionally + * suffixed with a known signature (provider:service:collection:signature) + * to request a delta relative to that signature + * * @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 }) - + const response = await entityService.delta({ targets }) + // Process delta and update store - Object.entries(response).forEach(([provider, providerData]) => { + Object.entries(response).forEach(([, providerData]) => { // Skip if no changes for provider if (providerData === false) return - - Object.entries(providerData).forEach(([service, serviceData]) => { + + Object.entries(providerData).forEach(([, serviceData]) => { // Skip if no changes for service if (serviceData === false) return - - Object.entries(serviceData).forEach(([collection, collectionData]) => { + + Object.entries(serviceData).forEach(([, collectionData]) => { // Skip if no changes for collection 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,6 +208,184 @@ export const useEntitiesStore = defineStore('peopleEntitiesStore', () => { } } + /** + * Create a new empty entity object + * + * @returns New entity object instance + */ + function fresh(): EntityObject { + return new EntityObject() + } + + /** + * Create a new entity with given collection identifier and properties + * + * @param target - collection identifier for the new entity + * @param properties - entity properties for creation + * + * @returns Promise with created entity object + */ + async function create(target: CollectionIdentifier, properties: EntityPropertiesInterface | IndividualObject | OrganizationObject | GroupObject): Promise { + transceiving.value = true + try { + if (properties instanceof IndividualObject || properties instanceof OrganizationObject || properties instanceof GroupObject) { + properties = properties.toJson() + } + const response = await entityService.create({ target, properties }) + + // Add created entity to state + _entities.value[response.identifier] = response + + console.debug('[People Manager][Store] - Successfully created entity:', response.identifier) + return response + } catch (error: any) { + console.error('[People Manager][Store] - Failed to create entity:', error) + throw error + } finally { + transceiving.value = false + } + } + + /** + * Update an existing entity with given entity identifier and properties + * + * @param target - entity identifier for the entity to update + * @param properties - entity properties for update + * + * @returns Promise with updated entity object + */ + async function update(target: EntityIdentifier, properties: EntityPropertiesInterface | IndividualObject | OrganizationObject | GroupObject): Promise { + transceiving.value = true + try { + if (properties instanceof IndividualObject || properties instanceof OrganizationObject || properties instanceof GroupObject) { + properties = properties.toJson() + } + const response = await entityService.update({ target, properties }) + + // Update entity in state + _entities.value[response.identifier] = response + + console.debug('[People Manager][Store] - Successfully updated entity:', response.identifier) + return response + } catch (error: any) { + console.error('[People Manager][Store] - Failed to update entity:', error) + throw error + } finally { + transceiving.value = false + } + } + + /** + * Delete entities by their identifiers. + * + * Removes successfully deleted entities from the local store. + * + * @param targets - entity identifiers to delete + * + * @returns Promise with successes/failures keyed by target identifier + */ + 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(`[People Manager][Store] - Entity delete on "${originalIdentifier}" returned an error: ${result.error})`) + failures.push(originalIdentifier) + return + } + + if (result.disposition !== 'moved' && result.disposition !== 'deleted') { + console.warn(`[People 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('[People Manager][Store] - Successfully deleted', successes.length, 'entities') + return { successes, failures } + } catch (error: any) { + console.error('[People Manager][Store] - Failed to delete entities:', error) + throw error + } finally { + transceiving.value = false + } + } + + /** + * Move entities to another collection. + * + * Updates local store keys for successfully moved entities when they are + * already present in cache. + * + * @param target - target collection identifier + * @param sources - source entity identifiers + * + * @returns Promise with successes/failures keyed by source identifier + */ + 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(`[People Manager][Store] - Entity move on "${originalIdentifier}" returned an error: ${result.error})`) + failures.push(originalIdentifier) + return + } + + if (result.disposition !== 'moved') { + console.warn(`[People 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('[People Manager][Store] - Successfully moved', successes.length, 'entities') + return { successes, failures } + } catch (error: any) { + console.error('[People Manager][Store] - Failed to move entities:', error) + throw error + } finally { + transceiving.value = false + } + } + // Return public API return { // State (readonly) @@ -338,9 +400,11 @@ export const useEntitiesStore = defineStore('peopleEntitiesStore', () => { list, fetch, extant, + fresh, create, update, delete: remove, delta, + move, } }) diff --git a/src/stores/providersStore.ts b/src/stores/providersStore.ts index f465ee0..bc445dd 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('peopleProvidersStore', () => { // State @@ -54,10 +54,10 @@ export const useProvidersStore = defineStore('peopleProvidersStore', () => { * * @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('peopleProvidersStore', () => { 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('peopleProvidersStore', () => { * * @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('peopleProvidersStore', () => { } }) - console.debug('[People Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'providers') + console.debug('[People Manager][Store] - Successfully checked', targets ? targets.length : 0, 'providers') return response } catch (error: any) { console.error('[People Manager][Store] - Failed to check providers:', error) diff --git a/src/stores/servicesStore.ts b/src/stores/servicesStore.ts index f9e28a3..0f86357 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' @@ -31,59 +32,78 @@ export const useServicesStore = defineStore('peopleServicesStore', () => { */ const services = computed(() => Object.values(_services.value)) + /** + * Get all enabled services present in store + */ + const servicesEnabled = computed(() => services.value.filter(service => service.enabled)) + /** * Get all services present in store grouped by provider */ const servicesByProvider = computed(() => { const groups: Record = {} - + Object.values(_services.value).forEach((service) => { const providerServices = (groups[service.provider] ??= []) providerServices.push(service) }) - + return groups }) /** * Get a specific service from store, with optional retrieval - * + * * @param provider - provider identifier * @param identifier - service identifier * @param retrieve - Retrieve behavior: true = fetch if missing or refresh, false = cache only - * + * * @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(`[People 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 + * + * @param identifier - unique service identifier + * @param retrieve - Retrieve behavior: true = fetch if missing or refresh, false = cache only + * @returns Service object or null + */ + function serviceByIdentifier(identifier: ServiceIdentifier, retrieve: boolean = false): ServiceObject | null { + if (retrieve === true && !_services.value[identifier]) { + console.debug(`[People 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 - + /** - * Retrieve all or specific services, optionally filtered by source selector - * - * @param sources - optional source selector - * + * Retrieve all or specific services, optionally filtered by provider/service identifiers + * + * @param targets - optional array of provider:service (or provider:service:collection) identifiers + * * @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 = {} @@ -106,20 +126,20 @@ export const useServicesStore = defineStore('peopleServicesStore', () => { transceiving.value = false } } - + /** * Retrieve a specific service by provider and identifier - * + * * @param provider - provider identifier * @param identifier - service identifier - * + * * @returns Promise with service object */ async function fetch(provider: string, identifier: string | number): Promise { transceiving.value = true try { const service = await serviceService.fetch({ provider, identifier }) - + // Merge fetched service into state const key = identifierKey(service.provider, service.identifier) _services.value[key] = service @@ -135,18 +155,18 @@ export const useServicesStore = defineStore('peopleServicesStore', () => { } /** - * Retrieve service availability status for a given source selector - * - * @param sources - source selector to check availability for - * + * Retrieve service availability status for the given service identifiers + * + * @param targets - array of provider:service identifiers to check availability for + * * @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 }) - - console.debug('[People Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'services') + const response = await serviceService.extant({ targets }) + + console.debug('[People Manager][Store] - Successfully checked', targets?.length ?? 0, 'services') return response } catch (error: any) { console.error('[People Manager][Store] - Failed to check services:', error) @@ -158,21 +178,21 @@ export const useServicesStore = defineStore('peopleServicesStore', () => { /** * Create a new service with given provider and data - * + * * @param provider - provider identifier for the new service * @param data - partial service data for creation - * + * * @returns Promise with created service object */ async function create(provider: string, data: Partial): Promise { transceiving.value = true try { const service = await serviceService.create({ provider, data }) - + // Merge created service into state const key = identifierKey(service.provider, service.identifier) _services.value[key] = service - + console.debug('[People Manager][Store] - Successfully created service:', key) return service } catch (error: any) { @@ -185,22 +205,28 @@ export const useServicesStore = defineStore('peopleServicesStore', () => { /** * Update an existing service with given provider, identifier, and data - * + * * @param provider - provider identifier for the service to update * @param identifier - service identifier for the service to update - * @param data - partial service data for update - * + * @param delta - whether the update is a delta (partial) update or a full replacement + * @param data - service data for update + * * @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 }) - + // convert ServiceObject to JSON if needed + 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) _services.value[key] = service - + console.debug('[People Manager][Store] - Successfully updated service:', key) return service } catch (error: any) { @@ -213,17 +239,17 @@ export const useServicesStore = defineStore('peopleServicesStore', () => { /** * Delete a service by provider and identifier - * + * * @param provider - provider identifier for the service to delete * @param identifier - service identifier for the service to delete - * + * * @returns Promise with deletion result */ async function remove(provider: string, identifier: string | number): Promise { transceiving.value = true try { await serviceService.delete({ provider, identifier }) - + // Remove deleted service from state const key = identifierKey(provider, identifier) delete _services.value[key] @@ -245,10 +271,11 @@ export const useServicesStore = defineStore('peopleServicesStore', () => { count, has, services, + servicesEnabled, servicesByProvider, - // Actions service, + serviceByIdentifier, list, fetch, extant, diff --git a/src/types/collection.ts b/src/types/collection.ts index 856f61e..a63b5d4 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 = 'individual' | 'organization' | 'group'; 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..6dd33be 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -44,34 +44,56 @@ 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; +} -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. + * ["carddav:account1:contacts", "carddav:account1:contacts: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 8c4295c..5a815a1 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 { GroupInterface } from './group'; import type { IndividualInterface } from './individual'; import type { OrganizationInterface } from './organization'; +export type EntityPropertiesInterface = IndividualInterface | OrganizationInterface | GroupInterface; + /** * 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..dbc0130 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 @@ -23,27 +23,30 @@ export interface ProviderCapabilitiesInterface { */ export interface ProviderInterface { '@type': string; + version: number; identifier: string; label: string; 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 +55,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 f583141..91c68f8 100644 --- a/src/types/service.ts +++ b/src/types/service.ts @@ -1,7 +1,12 @@ /** * Service type definitions */ -import type { SourceSelector, ListFilterComparisonOperator } from './common'; +import type { Identity } from '@/models/identity'; +import type { + ServiceIdentifier, + CollectionIdentifier, + ListFilterComparisonOperator +} from './common'; /** * Service capabilities @@ -37,6 +42,7 @@ export interface ServiceCapabilitiesInterface { */ export interface ServiceInterface { '@type': string; + version: number; provider: string; identifier: string | number | null; label: string | null; @@ -47,11 +53,18 @@ export interface ServiceInterface { auxiliary?: Record; // Provider-specific extension data } +export interface ServiceModelInterface extends Omit<{ + [K in keyof ServiceInterface]-?: Exclude; +}, '@type' | 'version' | '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; // If true, 'data' contains only fields to update (partial update). If false or omitted, 'data' is a full replacement. data: Partial; } @@ -125,7 +139,20 @@ export interface ServiceDiscoverRequest { } export interface ServiceDiscoverResponse { - [provider: string]: ServiceLocation; // Uses existing ServiceLocation discriminated union + provider: string; + location: ServiceLocation; +} + +export interface ProviderDiscoveryStatus { + provider: string; + status: 'pending' | 'discovering' | 'success' | 'failed'; + location?: ServiceLocation; + metadata?: { + host?: string; + port?: number; + protocol?: string; + }; + error?: string; } /**