From 4b37be3e7a760f8a09eaa33f6eea054c152c9469 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Sat, 5 Sep 2026 20:12:27 -0400 Subject: [PATCH] feat: use resource identifiers Signed-off-by: Sebastian Krupinski --- src/composables/useDocumentManager.ts | 98 +++++++++++++----- src/services/collectionService.ts | 39 ++----- src/services/entityService.ts | 2 +- src/services/nodeService.ts | 34 ++++++ src/services/transceive.ts | 8 +- src/stores/collectionsStore.ts | 70 +++++++------ src/stores/entitiesStore.ts | 110 +++++++++----------- src/stores/nodesStore.ts | 144 ++++++++++++++------------ src/types/collection.ts | 8 +- src/types/index.ts | 1 + src/types/node.ts | 23 ++++ 11 files changed, 313 insertions(+), 224 deletions(-) create mode 100644 src/services/nodeService.ts create mode 100644 src/types/node.ts diff --git a/src/composables/useDocumentManager.ts b/src/composables/useDocumentManager.ts index 0b20518..6abccfa 100644 --- a/src/composables/useDocumentManager.ts +++ b/src/composables/useDocumentManager.ts @@ -8,7 +8,7 @@ import type { Ref, ComputedRef } from 'vue' import { useProvidersStore } from '@/stores/providersStore' import { useServicesStore } from '@/stores/servicesStore' import { useNodesStore, ROOT_ID } from '@/stores/nodesStore' -import type { ListFilter, ListSort, ListRange } from '@/types/common' +import type { ListFilter, ListSort, ListRange, ServiceIdentifier, CollectionIdentifier } from '@/types/common' import type { DocumentInterface } from '@/types/document' import { CollectionObject } from '@/models/collection' import { EntityObject } from '@/models/entity' @@ -16,6 +16,9 @@ import { EntityObject } from '@/models/entity' // Base URL for file manager transfer endpoints const TRANSFER_BASE_URL = '/m/documents_manager' +// Number of collections+entities requested per page +const DEFAULT_PAGE_TALLY = 64 + export interface UseDocumentManagerOptions { providerId: string | Ref | ComputedRef serviceId: string | Ref | ComputedRef @@ -32,6 +35,12 @@ export function useDocumentManager(options: UseDocumentManagerOptions) { const currentProviderId = () => unref(providerId) const currentServiceId = () => unref(serviceId) + const currentSources = (): (ServiceIdentifier | CollectionIdentifier)[] => [ + currentLocation.value === ROOT_ID + ? `${currentProviderId()}:${currentServiceId()}` + : `${currentProviderId()}:${currentServiceId()}:${currentLocation.value}`, + ] + // Current location (folder being viewed) const currentLocation: Ref = ref(ROOT_ID) @@ -39,6 +48,13 @@ export function useDocumentManager(options: UseDocumentManagerOptions) { const isLoading = computed(() => nodesStore.transceiving) const error = computed(() => nodesStore.error) + // Pagination state for the current location + filter + sort + const currentFilter: Ref = ref(undefined) + const currentSort: Ref = ref(undefined) + const loadedCount = ref(0) + const hasMore = ref(false) + const isLoadingMore = ref(false) + // Provider and service const provider = computed(() => providersStore.provider(currentProviderId())) const service = computed(() => servicesStore.service(currentProviderId(), currentServiceId())) @@ -71,7 +87,7 @@ export function useDocumentManager(options: UseDocumentManagerOptions) { // Navigate to a folder const navigateTo = async (collectionId: string | null) => { currentLocation.value = collectionId || ROOT_ID - await refresh() + await resetAndLoad(currentFilter.value, currentSort.value) } // Navigate up one level @@ -90,28 +106,55 @@ export function useDocumentManager(options: UseDocumentManagerOptions) { await navigateTo(ROOT_ID) } - // Refresh current location - const refresh = async ( - filter?: ListFilter, - sort?: ListSort, - range?: ListRange - ) => { - await nodesStore.fetchNodes( - currentProviderId(), - currentServiceId(), - currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value, + // Reset the current location's loaded items and (re)fetch the first page, + // optionally with a new filter/sort (e.g. search query or sort field change) + const resetAndLoad = async (filter?: ListFilter, sort?: ListSort) => { + currentFilter.value = filter + currentSort.value = sort + loadedCount.value = 0 + hasMore.value = false + + const range: ListRange = { type: 'tally', anchor: 'absolute', position: 0, tally: DEFAULT_PAGE_TALLY } + const result = await nodesStore.fetchNodes( + currentSources(), filter, sort, - range + range, + 'reset' ) + + loadedCount.value = result.collections.length + result.entities.length + hasMore.value = !result.isLastPage + } + + // Fetch the next page of the current location (infinite scroll "load more") + const loadMore = async () => { + if (!hasMore.value || isLoadingMore.value) { + return + } + + isLoadingMore.value = true + try { + const range: ListRange = { type: 'tally', anchor: 'absolute', position: loadedCount.value, tally: DEFAULT_PAGE_TALLY } + const result = await nodesStore.fetchNodes( + currentSources(), + currentFilter.value, + currentSort.value, + range, + 'append' + ) + + loadedCount.value += result.collections.length + result.entities.length + hasMore.value = !result.isLastPage + } finally { + isLoadingMore.value = false + } } // Create a new folder const createFolder = async (label: string): Promise => { return await nodesStore.createCollection( - currentProviderId(), - currentServiceId(), - currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value, + `${currentProviderId()}:${currentServiceId()}:${currentLocation.value}`, { label, owner: '' } ) } @@ -132,9 +175,7 @@ export function useDocumentManager(options: UseDocumentManagerOptions) { } return await nodesStore.createEntity( - currentProviderId(), - currentServiceId(), - currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value, + `${currentProviderId()}:${currentServiceId()}:${currentLocation.value}`, properties ) } @@ -147,7 +188,7 @@ export function useDocumentManager(options: UseDocumentManagerOptions) { } if (node instanceof CollectionObject) { - return await nodesStore.updateCollection(currentProviderId(), currentServiceId(), nodeId, { + return await nodesStore.updateCollection(`${node.provider}:${node.service}:${nodeId}`, { label: newLabel, owner: node.properties.owner, }) @@ -161,7 +202,7 @@ export function useDocumentManager(options: UseDocumentManagerOptions) { format: node.properties.format, encoding: node.properties.encoding, } - return await nodesStore.updateEntity(currentProviderId(), currentServiceId(), node.collection, nodeId, properties) + return await nodesStore.updateEntity(`${node.provider}:${node.service}:${node.collection || ROOT_ID}:${nodeId}`, properties) } } @@ -173,9 +214,9 @@ export function useDocumentManager(options: UseDocumentManagerOptions) { } if (node instanceof CollectionObject) { - return await nodesStore.deleteCollection(currentProviderId(), currentServiceId(), nodeId) + return await nodesStore.deleteCollection(`${node.provider}:${node.service}:${nodeId}`) } else { - return await nodesStore.deleteEntity(currentProviderId(), currentServiceId(), node.collection, nodeId) + return await nodesStore.deleteEntity(`${node.provider}:${node.service}:${node.collection || ROOT_ID}:${nodeId}`) } } @@ -185,7 +226,7 @@ export function useDocumentManager(options: UseDocumentManagerOptions) { if (!node || !(node instanceof EntityObject)) { throw new Error('Entity not found') } - return await nodesStore.readEntity(currentProviderId(), currentServiceId(), node.collection || ROOT_ID, entityId) + return await nodesStore.readEntity(`${node.provider}:${node.service}:${node.collection || ROOT_ID}:${entityId}`) } // Write file content @@ -194,7 +235,7 @@ export function useDocumentManager(options: UseDocumentManagerOptions) { if (!node || !(node instanceof EntityObject)) { throw new Error('Entity not found') } - return await nodesStore.writeEntity(currentProviderId(), currentServiceId(), node.collection, entityId, content) + return await nodesStore.writeEntity(`${node.provider}:${node.service}:${node.collection || ROOT_ID}:${entityId}`, content) } // Get a URL suitable for inline viewing (img src / video src) @@ -253,7 +294,7 @@ export function useDocumentManager(options: UseDocumentManagerOptions) { await providersStore.list([currentProviderId()]) await servicesStore.list() if (autoFetch) { - await refresh() + await resetAndLoad() } } @@ -262,6 +303,8 @@ export function useDocumentManager(options: UseDocumentManagerOptions) { currentLocation, isLoading, error, + hasMore, + isLoadingMore, // Provider/Service provider, @@ -279,7 +322,8 @@ export function useDocumentManager(options: UseDocumentManagerOptions) { navigateTo, navigateUp, navigateToRoot, - refresh, + resetAndLoad, + loadMore, // Operations createFolder, diff --git a/src/services/collectionService.ts b/src/services/collectionService.ts index b146119..10f677b 100644 --- a/src/services/collectionService.ts +++ b/src/services/collectionService.ts @@ -25,23 +25,11 @@ import type { import { useIntegrationStore } from '@KTXC/stores/integrationStore'; import { CollectionObject, CollectionPropertiesObject } from '../models/collection'; -function isCollectionPayload(value: unknown): value is CollectionInterface { - if (!value || typeof value !== 'object') { - return false; - } - - const candidate = value as Record; - return ( - ('identifier' in candidate || 'provider' in candidate || 'service' in candidate) - && 'properties' in candidate - ); -} - /** * Helper to create the right collection model class based on provider identifier * Uses provider-specific factory if available, otherwise returns base CollectionObject */ -function createCollectionObject(data: CollectionInterface): CollectionObject { +export function createCollectionObject(data: CollectionInterface): CollectionObject { const integrationStore = useIntegrationStore(); const factoryItem = integrationStore.getItemById('documents_collection_factory', data.provider) as any; const factory = factoryItem?.factory; @@ -68,38 +56,27 @@ export const collectionService = { const serviceList: Record> = {}; Object.entries(providerServices).forEach(([serviceId, serviceCollections]) => { const collectionList: Record = {}; - Object.entries(serviceCollections as Record).forEach(([collectionId, collectionData]) => { - if (isCollectionPayload(collectionData)) { - collectionList[collectionId] = createCollectionObject(collectionData); - return; - } - - if (collectionData && typeof collectionData === 'object') { - Object.entries(collectionData as Record).forEach(([nestedCollectionId, nestedCollectionData]) => { - if (isCollectionPayload(nestedCollectionData)) { - collectionList[nestedCollectionId] = createCollectionObject(nestedCollectionData); - } - }); - } + Object.entries(serviceCollections).forEach(([collectionId, collectionData]) => { + collectionList[collectionId] = createCollectionObject(collectionData); }); serviceList[serviceId] = collectionList; }); providerList[providerId] = serviceList; }); - + return providerList; }, /** - * Retrieve a specific collection by provider and identifier + * Retrieve collections by resource identifier * * @param request - fetch request parameters * - * @returns Promise with collection object + * @returns Promise with collections keyed by resource identifier */ - async fetch(request: CollectionFetchRequest): Promise { + async fetch(request: CollectionFetchRequest): Promise> { const response = await transceivePost('collection.fetch', request); - return createCollectionObject(response); + return Object.fromEntries(Object.entries(response).map(([target, data]) => [target, createCollectionObject(data)])); }, /** diff --git a/src/services/entityService.ts b/src/services/entityService.ts index 0ae5101..c1b5089 100644 --- a/src/services/entityService.ts +++ b/src/services/entityService.ts @@ -35,7 +35,7 @@ import { EntityObject } from '../models'; * Helper to create the right entity model class based on provider identifier * Uses provider-specific factory if available, otherwise returns base EntityObject */ -function createEntityObject(data: EntityInterface): EntityObject { +export function createEntityObject(data: EntityInterface): EntityObject { const integrationStore = useIntegrationStore(); const factoryItem = integrationStore.getItemById('documents_entity_factory', data.provider) as any; const factory = factoryItem?.factory; diff --git a/src/services/nodeService.ts b/src/services/nodeService.ts new file mode 100644 index 0000000..0bdbc03 --- /dev/null +++ b/src/services/nodeService.ts @@ -0,0 +1,34 @@ +import { transceiveStream } from './transceive'; +import type { NodeListRequest, NodeInterface } from '../types/node'; +import { createCollectionObject } from './collectionService'; +import { createEntityObject } from './entityService'; +import type { CollectionObject } from '../models/collection'; +import type { EntityObject } from '../models/entity'; + +export type NodeListResult = (CollectionObject | EntityObject)[]; + +export const nodeService = { + /** Hydrate a mixed page in the exact order returned by the provider. */ + async list(request: NodeListRequest = {}): Promise { + const nodes: NodeListResult = []; + const { total } = await transceiveStream('node.list', request, node => { + switch (node['@type']) { + case 'document:collection': + nodes.push(createCollectionObject(node)); + break; + case 'document:entity': + nodes.push(createEntityObject(node)); + break; + default: + throw new Error('Unsupported node type in node.list response'); + } + }, { requireEnd: true }); + if (total !== nodes.length) { + throw new Error('Incomplete node.list stream'); + } + // Publish only complete pages so a retry uses the same offset after failure. + return nodes; + }, +}; + +export default nodeService; diff --git a/src/services/transceive.ts b/src/services/transceive.ts index 99a43a6..ed3d52f 100644 --- a/src/services/transceive.ts +++ b/src/services/transceive.ts @@ -62,7 +62,7 @@ export async function transceiveStream( operation: string, data: TRequest, onData: (data: TData) => void, - options?: { user?: string; onStart?: (expected?: number) => void } + options?: { user?: string; onStart?: (expected?: number) => void; requireEnd?: boolean } ): Promise<{ total: number }> { const request: ApiRequest = { version: API_VERSION, @@ -73,6 +73,7 @@ export async function transceiveStream( }; let total = 0; + let ended = false; const dispatch = (line: string): void => { const message = JSON.parse(line) as ApiStreamResponse; @@ -82,6 +83,7 @@ export async function transceiveStream( options?.onStart?.(message.total); } else if (message.status === 'end') { total = message.total; + ended = true; } return; } @@ -118,6 +120,7 @@ export async function transceiveStream( } } + buffer += decoder.decode(); if (buffer.trim()) dispatch(buffer); } finally { reader.releaseLock(); @@ -125,5 +128,8 @@ export async function transceiveStream( }, }); + if (options?.requireEnd && !ended) { + throw new Error(`[${operation}] Stream ended without a completion frame`); + } return { total }; } diff --git a/src/stores/collectionsStore.ts b/src/stores/collectionsStore.ts index 2cc64ce..4372154 100644 --- a/src/stores/collectionsStore.ts +++ b/src/stores/collectionsStore.ts @@ -40,25 +40,25 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () = return `${provider}:${service ?? ''}:${identifier ?? ''}` } - function collection(provider: string, service: string | number, identifier: string | number, retrieve: boolean = false): CollectionObject | null { - const key = identifierKey(provider, service, identifier) + function collection(target: CollectionIdentifier, retrieve: boolean = false): CollectionObject | null { + const key = target if (retrieve === true && !_collections.value[key]) { console.debug(`[Documents Manager][Store] - Force fetching collection "${key}"`) - fetch(provider, service, identifier) + void fetch([target]) } return _collections.value[key] || null } - function collectionsForService(provider: string, service: string | number): CollectionObject[] { - const serviceKeyPrefix = `${provider}:${service}:` + function collectionsForService(target: ServiceIdentifier): CollectionObject[] { + const serviceKeyPrefix = `${target}:` return Object.entries(_collections.value) .filter(([key]) => key.startsWith(serviceKeyPrefix)) .map(([_, collectionObj]) => collectionObj) } - function clearService(provider: string, service: string | number): void { - const serviceKeyPrefix = `${provider}:${service}:` + function clearService(target: ServiceIdentifier): void { + const serviceKeyPrefix = `${target}:` Object.keys(_collections.value) .filter((key) => key.startsWith(serviceKeyPrefix)) .forEach((key) => { @@ -70,6 +70,27 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () = _collections.value = {} } + /** + * Merge externally-hydrated collections into the store (used by paginated node fetches) + */ + function mergeMany(collections: CollectionObject[]): void { + const hydrated: Record = {} + collections.forEach((collectionObj) => { + const key = identifierKey(collectionObj.provider, collectionObj.service, collectionObj.identifier) + hydrated[key] = collectionObj + }) + _collections.value = { ..._collections.value, ...hydrated } + } + + /** + * Remove specific collections by their own identifiers (used to evict a stale page before a reset) + */ + function removeMany(targets: CollectionIdentifier[]): void { + targets.forEach((target) => { + delete _collections.value[target] + }) + } + // Actions async function list(sources?: (ServiceIdentifier | CollectionIdentifier)[], filter?: ListFilter, sort?: ListSort): Promise> { transceiving.value = true @@ -98,14 +119,11 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () = } } - 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({ targets: [`${provider}:${service}:${identifier}`] }) - const key = identifierKey(response.provider, response.service, response.identifier) - _collections.value[key] = response - - console.debug('[Documents Manager][Store] - Successfully fetched collection:', key) + const response = await collectionService.fetch({ targets }) + _collections.value = { ..._collections.value, ...response } return response } catch (error: any) { console.error('[Documents Manager][Store] - Failed to fetch collection:', error) @@ -130,17 +148,12 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () = } async function create( - provider: string, - service: string | number, - collection: string | number | null, + target: ServiceIdentifier | CollectionIdentifier, properties: CollectionMutableProperties, ): Promise { transceiving.value = true try { - const target: CollectionIdentifier | undefined = collection !== null && collection !== undefined && collection !== '' - ? `${provider}:${service}:${collection}` - : undefined - const response = await collectionService.create({ provider, service: String(service), target, properties }) + const response = await collectionService.create({ target, properties }) const key = identifierKey(response.provider, response.service, response.identifier) _collections.value[key] = response @@ -155,14 +168,12 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () = } async function update( - provider: string, - service: string | number, - identifier: string | number, + target: CollectionIdentifier, properties: CollectionMutableProperties, ): Promise { transceiving.value = true try { - const response = await collectionService.update({ target: `${provider}:${service}:${identifier}`, properties }) + const response = await collectionService.update({ target, properties }) const key = identifierKey(response.provider, response.service, response.identifier) _collections.value[key] = response @@ -176,17 +187,16 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () = } } - async function remove(provider: string, service: string | number, identifier: string | number): Promise<{ success: boolean }> { + async function remove(target: CollectionIdentifier): Promise<{ success: boolean }> { transceiving.value = true try { - const response = await collectionService.delete({ target: `${provider}:${service}:${identifier}` }) + const response = await collectionService.delete({ target }) const success = response.disposition === 'deleted' || response.disposition === 'moved' if (success) { - const key = identifierKey(provider, service, identifier) - delete _collections.value[key] + delete _collections.value[target] } - console.debug('[Documents Manager][Store] - Successfully deleted collection:', `${provider}:${service}:${identifier}`) + console.debug('[Documents Manager][Store] - Successfully deleted collection:', target) return { success } } catch (error: any) { console.error('[Documents Manager][Store] - Failed to delete collection:', error) @@ -206,6 +216,8 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () = collectionsForService, clearService, clearAll, + mergeMany, + removeMany, list, fetch, extant, diff --git a/src/stores/entitiesStore.ts b/src/stores/entitiesStore.ts index 40b764b..bd177b3 100644 --- a/src/stores/entitiesStore.ts +++ b/src/stores/entitiesStore.ts @@ -49,37 +49,34 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { } function entity( - provider: string, - service: string | number, - collection: string | number, - identifier: string | number, + target: EntityIdentifier, retrieve: boolean = false, ): EntityObject | null { - const key = identifierKey(provider, service, collection, identifier) + const key = target if (retrieve === true && !_entities.value[key]) { console.debug(`[Documents Manager][Store] - Force fetching entity "${key}"`) - fetch(provider, service, collection, [identifier]) + void fetch([target]) } return _entities.value[key] || null } - function entitiesForService(provider: string, service: string | number): EntityObject[] { - const serviceKeyPrefix = `${provider}:${service}:` + function entitiesForService(target: ServiceIdentifier): EntityObject[] { + const serviceKeyPrefix = `${target}:` return Object.entries(_entities.value) .filter(([key]) => key.startsWith(serviceKeyPrefix)) .map(([_, entityObj]) => entityObj) } - function entitiesForCollection(provider: string, service: string | number, collection: string | number): EntityObject[] { - const collectionKeyPrefix = `${provider}:${service}:${collection}:` + function entitiesForCollection(target: CollectionIdentifier): EntityObject[] { + const collectionKeyPrefix = `${target}:` return Object.entries(_entities.value) .filter(([key]) => key.startsWith(collectionKeyPrefix)) .map(([_, entityObj]) => entityObj) } - function clearService(provider: string, service: string | number): void { - const serviceKeyPrefix = `${provider}:${service}:` + function clearService(target: ServiceIdentifier): void { + const serviceKeyPrefix = `${target}:` Object.keys(_entities.value) .filter((key) => key.startsWith(serviceKeyPrefix)) .forEach((key) => { @@ -87,8 +84,8 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { }) } - function clearCollection(provider: string, service: string | number, collection: string | number): void { - const collectionKeyPrefix = `${provider}:${service}:${collection}:` + function clearCollection(target: CollectionIdentifier): void { + const collectionKeyPrefix = `${target}:` Object.keys(_entities.value) .filter((key) => key.startsWith(collectionKeyPrefix)) .forEach((key) => { @@ -100,6 +97,18 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { _entities.value = {} } + /** + * Merge externally-hydrated entities into the store (used by paginated node fetches) + */ + function mergeMany(entities: EntityObject[]): void { + const hydrated: Record = {} + entities.forEach((entityObj) => { + const key = identifierKey(entityObj.provider, entityObj.service, entityObj.collection, entityObj.identifier) + hydrated[key] = entityObj + }) + _entities.value = { ..._entities.value, ...hydrated } + } + // Actions async function list(sources?: (ServiceIdentifier | CollectionIdentifier)[], filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise> { transceiving.value = true @@ -131,25 +140,13 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { } async function fetch( - provider: string, - service: string | number, - collection: string | number, - identifiers: (string | number)[], + targets: EntityIdentifier[], ): Promise> { transceiving.value = true try { - const targets: EntityIdentifier[] = identifiers.map((identifier) => `${provider}:${service}:${collection}:${identifier}` as EntityIdentifier) const response = await entityService.fetch({ targets }) - - const hydrated: Record = {} - Object.entries(response).forEach(([identifier, entityObj]) => { - const key = identifierKey(provider, service, collection, identifier) - hydrated[key] = entityObj - _entities.value[key] = entityObj - }) - - console.debug('[Documents Manager][Store] - Successfully fetched', Object.keys(hydrated).length, 'entities') - return hydrated + _entities.value = { ..._entities.value, ...response } + return response } catch (error: any) { console.error('[Documents Manager][Store] - Failed to fetch entities:', error) throw error @@ -173,15 +170,13 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { } async function create( - provider: string, - service: string | number, - collection: string | number, + target: CollectionIdentifier, properties: DocumentInterface, options?: Record, ): Promise { transceiving.value = true try { - const response = await entityService.create({ target: `${provider}:${service}:${collection}`, properties, options }) + const response = await entityService.create({ target, properties, options }) const key = identifierKey(response.provider, response.service, response.collection, response.identifier) _entities.value[key] = response @@ -196,15 +191,12 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { } async function update( - provider: string, - service: string | number, - collection: string | number, - identifier: string | number, + target: EntityIdentifier, properties: DocumentInterface, ): Promise { transceiving.value = true try { - const response = await entityService.update({ target: `${provider}:${service}:${collection}:${identifier}`, properties }) + const response = await entityService.update({ target, properties }) const key = identifierKey(response.provider, response.service, response.collection, response.identifier) _entities.value[key] = response @@ -219,23 +211,22 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { } async function remove( - provider: string, - service: string | number, - collection: string | number, - identifier: string | number, - ): Promise<{ success: boolean }> { + targets: EntityIdentifier[], + ): Promise<{ successes: EntityIdentifier[]; failures: EntityIdentifier[] }> { transceiving.value = true try { - const target: EntityIdentifier = `${provider}:${service}:${collection}:${identifier}` - const response = await entityService.delete({ targets: [target] }) - const success = response[target]?.disposition === 'deleted' - if (success) { - const key = identifierKey(provider, service, collection, identifier) - delete _entities.value[key] + const response = await entityService.delete({ targets }) + const successes: EntityIdentifier[] = [] + const failures: EntityIdentifier[] = [] + for (const target of targets) { + if (response[target]?.disposition === 'deleted') { + delete _entities.value[target] + successes.push(target) + } else { + failures.push(target) + } } - - console.debug('[Documents Manager][Store] - Successfully deleted entity:', target) - return { success } + return { successes, failures } } catch (error: any) { console.error('[Documents Manager][Store] - Failed to delete entity:', error) throw error @@ -277,14 +268,11 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { } async function read( - provider: string, - service: string | number, - collection: string | number, - identifier: string | number, + target: EntityIdentifier, ): Promise { transceiving.value = true try { - const response = await entityService.read({ target: `${provider}:${service}:${collection}:${identifier}` }) + const response = await entityService.read({ target }) return response.content } catch (error: any) { console.error('[Documents Manager][Store] - Failed to read entity:', error) @@ -295,15 +283,12 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { } async function write( - provider: string, - service: string | number, - collection: string | number, - identifier: string | number, + target: EntityIdentifier, content: string, ): Promise { transceiving.value = true try { - const response = await entityService.write({ target: `${provider}:${service}:${collection}:${identifier}`, content, encoding: 'base64' }) + const response = await entityService.write({ target, content, encoding: 'base64' }) return response.bytesWritten } catch (error: any) { console.error('[Documents Manager][Store] - Failed to write entity:', error) @@ -325,6 +310,7 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { clearService, clearCollection, clearAll, + mergeMany, list, fetch, extant, diff --git a/src/stores/nodesStore.ts b/src/stores/nodesStore.ts index 25944f1..719b800 100644 --- a/src/stores/nodesStore.ts +++ b/src/stores/nodesStore.ts @@ -6,6 +6,7 @@ import { computed, ref, readonly } from 'vue' import { defineStore } from 'pinia' import type { CollectionIdentifier, + EntityIdentifier, ServiceIdentifier, ListFilter, ListSort, @@ -16,6 +17,7 @@ import type { import { CollectionObject, EntityObject } from '../models' import { useCollectionsStore } from './collectionsStore' import { useEntitiesStore } from './entitiesStore' +import { nodeService } from '../services/nodeService' export const ROOT_ID = '00000000-0000-0000-0000-000000000000' @@ -46,8 +48,8 @@ export const useNodesStore = defineStore('documentsNodesStore', () => { function getServiceNodes(providerId: string, serviceId: string | number): NodeRecord[] { return [ - ...collectionsStore.collectionsForService(providerId, serviceId), - ...entitiesStore.entitiesForService(providerId, serviceId), + ...collectionsStore.collectionsForService(`${providerId}:${serviceId}`), + ...entitiesStore.entitiesForService(`${providerId}:${serviceId}`), ] } @@ -105,20 +107,13 @@ export const useNodesStore = defineStore('documentsNodesStore', () => { } async function fetchCollections( - providerId: string, - serviceId: string | number, - collectionId: string | number | null, + sources?: (ServiceIdentifier | CollectionIdentifier)[], filter?: ListFilter, sort?: ListSort, ): Promise { error.value = null try { - const sources: (ServiceIdentifier | CollectionIdentifier)[] = collectionId === null - ? [`${providerId}:${serviceId}`] - : [`${providerId}:${serviceId}:${collectionId}`] - - await collectionsStore.list(sources, filter, sort) - return collectionsStore.collectionsForService(providerId, serviceId) + return Object.values(await collectionsStore.list(sources, filter, sort)) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to fetch collections' throw e @@ -126,43 +121,76 @@ export const useNodesStore = defineStore('documentsNodesStore', () => { } async function fetchEntities( - providerId: string, - serviceId: string | number, - collectionId: string | number | null, + sources?: (ServiceIdentifier | CollectionIdentifier)[], filter?: ListFilter, sort?: ListSort, range?: ListRange, ): Promise { error.value = null try { - const sources: (ServiceIdentifier | CollectionIdentifier)[] = collectionId === null - ? [`${providerId}:${serviceId}`] - : [`${providerId}:${serviceId}:${collectionId}`] - - await entitiesStore.list(sources, filter, sort, range) - return entitiesStore.entitiesForCollection(providerId, serviceId, collectionId) + return Object.values(await entitiesStore.list(sources, filter, sort, range)) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to fetch entities' throw e } } + /** + * Fetch a unified node listing, optionally limited to a page by range. + */ async function fetchNodes( - providerId: string, - serviceId: string | number, - parentId: string | number | null = ROOT_ID, + sources?: (ServiceIdentifier | CollectionIdentifier)[], filter?: ListFilter, sort?: ListSort, range?: ListRange, - ): Promise { + mode: 'reset' | 'append' = 'reset', + ): Promise<{ collections: CollectionObject[]; entities: EntityObject[]; isLastPage: boolean }> { error.value = null try { - await Promise.all([ - fetchCollections(providerId, serviceId, parentId, filter, sort), - fetchEntities(providerId, serviceId, parentId, filter, sort, range), - ]) + const result = await nodeService.list({ sources, filter, sort, range }) - return getChildren(providerId, serviceId, parentId) + // Service selectors list the root; collection selectors list that folder. + // Omitted sources list roots across services, not every cached descendant. + const locations = new Map() + if (sources?.length) { + for (const source of sources) { + const [provider, service, parent] = source.split(':') + const location = { provider: provider!, service: service!, parent: isRoot(parent) ? ROOT_ID : parent! } + locations.set(`${location.provider}:${location.service}:${location.parent}`, location) + } + } else { + for (const node of [...nodeList.value, ...result]) { + const location = { provider: node.provider, service: String(node.service), parent: ROOT_ID } + locations.set(`${location.provider}:${location.service}:${ROOT_ID}`, location) + } + } + + // Replace only the selected locations, and only after the stream succeeds. + if (mode === 'reset') { + for (const { provider, service, parent } of locations.values()) { + const staleIds = getChildCollections(provider, service, parent) + .map((node): CollectionIdentifier => `${node.provider}:${node.service}:${node.identifier}`) + collectionsStore.removeMany(staleIds) + entitiesStore.clearCollection(`${provider}:${service}:${parent}`) + } + } + + const pageCollections = result.filter((node): node is CollectionObject => node instanceof CollectionObject) + const pageEntities = result.filter((node): node is EntityObject => node instanceof EntityObject) + + collectionsStore.mergeMany(pageCollections) + entitiesStore.mergeMany(pageEntities) + + // The backend applies the range independently to each selected location. + const counts = new Map() + for (const node of result) { + const parent = isRoot(node.collection) ? ROOT_ID : String(node.collection) + const key = `${node.provider}:${node.service}:${parent}` + counts.set(key, (counts.get(key) ?? 0) + 1) + } + const isLastPage = range === undefined || [...counts.values()].every(count => count < range.tally) + + return { collections: pageCollections, entities: pageEntities, isLastPage } } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to fetch nodes' throw e @@ -170,14 +198,12 @@ export const useNodesStore = defineStore('documentsNodesStore', () => { } async function createCollection( - providerId: string, - serviceId: string | number, - parentCollectionId: string | number | null, + target: ServiceIdentifier | CollectionIdentifier, properties: CollectionMutableProperties, ): Promise { error.value = null try { - return await collectionsStore.create(providerId, serviceId, parentCollectionId, properties) + return await collectionsStore.create(target, properties) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to create collection' throw e @@ -185,14 +211,12 @@ export const useNodesStore = defineStore('documentsNodesStore', () => { } async function updateCollection( - providerId: string, - serviceId: string | number, - identifier: string | number, + target: CollectionIdentifier, properties: CollectionMutableProperties, ): Promise { error.value = null try { - return await collectionsStore.update(providerId, serviceId, identifier, properties) + return await collectionsStore.update(target, properties) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to update collection' throw e @@ -200,13 +224,11 @@ export const useNodesStore = defineStore('documentsNodesStore', () => { } async function deleteCollection( - providerId: string, - serviceId: string | number, - identifier: string | number, + target: CollectionIdentifier, ): Promise { error.value = null try { - const response = await collectionsStore.delete(providerId, serviceId, identifier) + const response = await collectionsStore.delete(target) return response.success } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to delete collection' @@ -215,15 +237,13 @@ export const useNodesStore = defineStore('documentsNodesStore', () => { } async function createEntity( - providerId: string, - serviceId: string | number, - collectionId: string | number, + target: CollectionIdentifier, properties: DocumentInterface, options?: Record, ): Promise { error.value = null try { - return await entitiesStore.create(providerId, serviceId, collectionId, properties, options) + return await entitiesStore.create(target, properties, options) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to create entity' throw e @@ -231,15 +251,12 @@ export const useNodesStore = defineStore('documentsNodesStore', () => { } async function updateEntity( - providerId: string, - serviceId: string | number, - collectionId: string | number, - identifier: string | number, + target: EntityIdentifier, properties: DocumentInterface, ): Promise { error.value = null try { - return await entitiesStore.update(providerId, serviceId, collectionId, identifier, properties) + return await entitiesStore.update(target, properties) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to update entity' throw e @@ -247,15 +264,12 @@ export const useNodesStore = defineStore('documentsNodesStore', () => { } async function deleteEntity( - providerId: string, - serviceId: string | number, - collectionId: string | number, - identifier: string | number, + target: EntityIdentifier, ): Promise { error.value = null try { - const response = await entitiesStore.delete(providerId, serviceId, collectionId, identifier) - return response.success + const response = await entitiesStore.delete([target]) + return response.successes.includes(target) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to delete entity' throw e @@ -263,14 +277,11 @@ export const useNodesStore = defineStore('documentsNodesStore', () => { } async function readEntity( - providerId: string, - serviceId: string | number, - collectionId: string | number, - identifier: string | number, + target: EntityIdentifier, ): Promise { error.value = null try { - return await entitiesStore.read(providerId, serviceId, collectionId, identifier) + return await entitiesStore.read(target) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to read entity' throw e @@ -278,15 +289,12 @@ export const useNodesStore = defineStore('documentsNodesStore', () => { } async function writeEntity( - providerId: string, - serviceId: string | number, - collectionId: string | number, - identifier: string | number, + target: EntityIdentifier, content: string, ): Promise { error.value = null try { - return await entitiesStore.write(providerId, serviceId, collectionId, identifier, content) + return await entitiesStore.write(target, content) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to write entity' throw e @@ -294,8 +302,8 @@ export const useNodesStore = defineStore('documentsNodesStore', () => { } function clearServiceNodes(providerId: string, serviceId: string | number): void { - collectionsStore.clearService(providerId, serviceId) - entitiesStore.clearService(providerId, serviceId) + collectionsStore.clearService(`${providerId}:${serviceId}`) + entitiesStore.clearService(`${providerId}:${serviceId}`) } function clearNodes(): void { diff --git a/src/types/collection.ts b/src/types/collection.ts index df37ab2..5244269 100644 --- a/src/types/collection.ts +++ b/src/types/collection.ts @@ -66,7 +66,7 @@ export interface CollectionFetchRequest { targets: CollectionIdentifier[]; } -export interface CollectionFetchResponse extends CollectionInterface {} +export type CollectionFetchResponse = Record; /** * Collection extant @@ -87,9 +87,7 @@ export interface CollectionExtantResponse { * Collection create */ export interface CollectionCreateRequest { - provider: string; - service: string | number; - target?: CollectionIdentifier | null; // Parent collection identifier (absent for root) + target: ServiceIdentifier | CollectionIdentifier; // Service root or parent collection properties: CollectionMutableProperties; options?: Record; } @@ -139,4 +137,4 @@ export interface CollectionMoveRequest { target?: CollectionIdentifier | null; // Destination parent (absent for root) } -export interface CollectionMoveResponse extends CollectionInterface {} \ No newline at end of file +export interface CollectionMoveResponse extends CollectionInterface {} diff --git a/src/types/index.ts b/src/types/index.ts index d78ba26..b96fc93 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,4 +3,5 @@ export type * from './provider'; export type * from './service'; export type * from './collection'; export type * from './entity'; +export type * from './node'; export type * from './document'; diff --git a/src/types/node.ts b/src/types/node.ts new file mode 100644 index 0000000..2453cb5 --- /dev/null +++ b/src/types/node.ts @@ -0,0 +1,23 @@ +/** + * Node (unified collections + entities) type definitions + */ +import type { CollectionIdentifier, ListFilter, ListRange, ListSort, ServiceIdentifier } from './common'; +import type { CollectionInterface } from './collection'; +import type { EntityInterface } from './entity'; + +/** + * Node list + */ +export interface NodeListRequest { + sources?: (ServiceIdentifier | CollectionIdentifier)[]; + filter?: ListFilter; + sort?: ListSort; + range?: ListRange; +} + +export type NodeInterface = + | (Omit & { '@type': 'document:collection' }) + | (Omit & { '@type': 'document:entity' }); + +/** Payload of each data frame in the node.list NDJSON stream. */ +export type NodeListResponse = NodeInterface;