diff --git a/bun.lockb b/bun.lockb index ecf187af96..1a93241bbc 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/client-query/package.json b/packages/client-query/package.json new file mode 100644 index 0000000000..b5582f16cd --- /dev/null +++ b/packages/client-query/package.json @@ -0,0 +1,19 @@ +{ + "name": "@communication/client-query", + "version": "0.1.0", + "main": "src/index.ts", + "module": "src/index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "^1.1.14" + }, + "dependencies": { + "@communication/types": "workspace:*", + "@communication/sdk-types": "workspace:*", + "@communication/query": "workspace:*", + "fast-equals": "^5.0.1" + }, + "peerDependencies": { + "typescript": "^5.6.3" + } +} diff --git a/packages/client-query/src/index.ts b/packages/client-query/src/index.ts new file mode 100644 index 0000000000..4a1c969701 --- /dev/null +++ b/packages/client-query/src/index.ts @@ -0,0 +1,26 @@ +import { LiveQueries } from '@communication/query' +import type { Client } from '@communication/sdk-types' + +import { MessagesQuery, NotificationsQuery } from './query' + +let lq: LiveQueries + +export function createMessagesQuery(): MessagesQuery { + return new MessagesQuery(lq) +} + +export function createNotificationsQuery(): NotificationsQuery { + return new NotificationsQuery(lq) +} + +export function initLiveQueries(client: Client) { + if (lq != null) { + lq.close() + } + + lq = new LiveQueries(client) + + client.onEvent = (event) => { + void lq.onEvent(event) + } +} diff --git a/packages/client-query/src/query.ts b/packages/client-query/src/query.ts new file mode 100644 index 0000000000..bbaad06b38 --- /dev/null +++ b/packages/client-query/src/query.ts @@ -0,0 +1,65 @@ +import { type LiveQueries } from '@communication/query' +import type { MessagesQueryCallback, NotificationsQueryCallback, QueryCallback } from '@communication/sdk-types' +import { type FindMessagesParams, type FindNotificationsParams } from '@communication/types' +import { deepEqual } from 'fast-equals' + +class BaseQuery

, C extends QueryCallback> { + private oldQuery: P | undefined + private oldCallback: QueryCallback | undefined + + constructor(protected readonly lq: LiveQueries) {} + + unsubscribe: () => void = () => {} + + query(params: P, callback: C): boolean { + if (!this.needUpdate(params, callback)) { + return false + } + this.doQuery(params, callback) + return true + } + + private doQuery(query: P, callback: C): void { + this.unsubscribe() + this.oldCallback = callback + this.oldQuery = query + + const { unsubscribe } = this.createQuery(query, callback) + this.unsubscribe = () => { + unsubscribe() + this.oldCallback = undefined + this.oldQuery = undefined + this.unsubscribe = () => {} + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + createQuery(params: P, callback: C): { unsubscribe: () => void } { + return { + unsubscribe: () => {} + } + } + + private needUpdate(params: FindMessagesParams, callback: MessagesQueryCallback): boolean { + if (!deepEqual(params, this.oldQuery)) return true + if (!deepEqual(callback.toString(), this.oldCallback?.toString())) return true + return false + } +} + +export class MessagesQuery extends BaseQuery { + override createQuery(params: FindMessagesParams, callback: MessagesQueryCallback): { unsubscribe: () => void } { + return this.lq.queryMessages(params, callback) + } +} + +export class NotificationsQuery extends BaseQuery { + override createQuery( + params: FindNotificationsParams, + callback: NotificationsQueryCallback + ): { + unsubscribe: () => void + } { + return this.lq.queryNotifications(params, callback) + } +} diff --git a/packages/client-query/tsconfig.json b/packages/client-query/tsconfig.json new file mode 100644 index 0000000000..3ae07cd3fa --- /dev/null +++ b/packages/client-query/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/packages/client-sqlite/src/client.ts b/packages/client-sqlite/src/client.ts index fb0edd5c66..4b50d2e06c 100644 --- a/packages/client-sqlite/src/client.ts +++ b/packages/client-sqlite/src/client.ts @@ -17,7 +17,7 @@ import { type MessageCreatedEvent, type DbAdapter, EventType, - type BroadcastEvent, + type BroadcastEvent } from '@communication/sdk-types' import { createDbAdapter as createSqliteDbAdapter } from '@communication/sqlite-wasm' diff --git a/packages/query/package.json b/packages/query/package.json new file mode 100644 index 0000000000..f613302134 --- /dev/null +++ b/packages/query/package.json @@ -0,0 +1,19 @@ +{ + "name": "@communication/query", + "version": "0.1.0", + "main": "src/index.ts", + "module": "src/index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "^1.1.14", + "@types/crypto-js": "^4.2.2" + }, + "dependencies": { + "@communication/types": "workspace:*", + "@communication/sdk-types": "workspace:*", + "fast-equals": "^5.0.1" + }, + "peerDependencies": { + "typescript": "^5.6.3" + } +} diff --git a/packages/query/src/index.ts b/packages/query/src/index.ts new file mode 100644 index 0000000000..57ad51bd4b --- /dev/null +++ b/packages/query/src/index.ts @@ -0,0 +1 @@ +export * from './lq.ts' diff --git a/packages/query/src/lq.ts b/packages/query/src/lq.ts new file mode 100644 index 0000000000..d19baf7c8a --- /dev/null +++ b/packages/query/src/lq.ts @@ -0,0 +1,150 @@ +import { type FindMessagesParams, type FindNotificationsParams } from '@communication/types' +import { deepEqual } from 'fast-equals' +import type { + Client, + MessagesQueryCallback, + NotificationsQueryCallback, + BroadcastEvent +} from '@communication/sdk-types' + +import type { Query, QueryId } from './types' +import { MessagesQuery } from './messages/query' +import { NotificationQuery } from './notifications/query' + +interface CreateQueryResult { + unsubscribe: () => void +} + +const maxQueriesCache = 10 + +export class LiveQueries { + private readonly client: Client + private readonly queries = new Map() + private readonly unsubscribed = new Set() + private counter: number = 0 + + constructor(client: Client) { + this.client = client + } + + async onEvent(event: BroadcastEvent): Promise { + for (const q of this.queries.values()) { + await q.onEvent(event) + } + } + + queryMessages(params: FindMessagesParams, callback: MessagesQueryCallback): CreateQueryResult { + const query = this.createMessagesQuery(params, callback) + this.queries.set(query.id, query) + + return { + unsubscribe: () => { + this.unsubscribeQuery(query) + } + } + } + + queryNotifications(params: FindNotificationsParams, callback: NotificationsQueryCallback): CreateQueryResult { + const query = this.createNotificationQuery(params, callback) + this.queries.set(query.id, query) + + return { + unsubscribe: () => { + this.unsubscribeQuery(query) + } + } + } + + private createMessagesQuery(params: FindMessagesParams, callback: MessagesQueryCallback): MessagesQuery { + const id = ++this.counter + const exists = this.findMessagesQuery(params) + + if (exists !== undefined) { + if (this.unsubscribed.has(id)) { + this.unsubscribed.delete(id) + exists.setCallback(callback) + return exists + } else { + const result = exists.copyResult() + return new MessagesQuery(this.client, id, params, callback, result) + } + } + + return new MessagesQuery(this.client, id, params, callback) + } + + private createNotificationQuery( + params: FindNotificationsParams, + callback: NotificationsQueryCallback + ): NotificationQuery { + const id = ++this.counter + const exists = this.findNotificationQuery(params) + + if (exists !== undefined) { + if (this.unsubscribed.has(id)) { + this.unsubscribed.delete(id) + exists.setCallback(callback) + return exists + } else { + const result = exists.copyResult() + return new NotificationQuery(this.client, id, params, callback, result) + } + } + + return new NotificationQuery(this.client, id, params, callback) + } + + private findMessagesQuery(params: FindMessagesParams): MessagesQuery | undefined { + for (const query of this.queries.values()) { + if (query instanceof MessagesQuery) { + if (!this.queryCompare(params, query.params)) continue + return query + } + } + } + + private findNotificationQuery(params: FindMessagesParams): NotificationQuery | undefined { + for (const query of this.queries.values()) { + if (query instanceof NotificationQuery) { + if (!this.queryCompare(params, query.params)) continue + return query + } + } + } + + private queryCompare(q1: FindMessagesParams, q2: FindMessagesParams): boolean { + if (Object.keys(q1).length !== Object.keys(q2).length) { + return false + } + return deepEqual(q1, q2) + } + + private removeOldQueries(): void { + const unsubscribed = Array.from(this.unsubscribed) + for (let i = 0; i < this.unsubscribed.size / 2; i++) { + const id = unsubscribed.shift() + if (id === undefined) return + this.unsubscribe(id) + } + } + + private unsubscribe(id: QueryId): void { + const query = this.queries.get(id) + if (query == null) return + void query.unsubscribe() + this.queries.delete(id) + this.unsubscribed.delete(id) + } + + private unsubscribeQuery(query: Query): void { + this.unsubscribed.add(query.id) + query.removeCallback() + if (this.unsubscribed.size > maxQueriesCache) { + this.removeOldQueries() + } + } + + close(): void { + this.queries.clear() + } +} diff --git a/packages/query/src/messages/query.ts b/packages/query/src/messages/query.ts new file mode 100644 index 0000000000..794b346c26 --- /dev/null +++ b/packages/query/src/messages/query.ts @@ -0,0 +1,202 @@ +import { + type CardID, + type FindMessagesParams, + type ID, + type Message, + type Patch, + SortOrder +} from '@communication/types' +import { + type AttachmentCreatedEvent, + type MessageCreatedEvent, + type PatchCreatedEvent, + type ReactionCreatedEvent, + EventType, + type BroadcastEvent, + type AttachmentRemovedEvent, + type MessageRemovedEvent, + type ReactionRemovedEvent +} from '@communication/sdk-types' + +import { BaseQuery } from '../query' + +export class MessagesQuery extends BaseQuery { + override async find(params: FindMessagesParams): Promise { + return this.client.findMessages(params, this.id) + } + + override getObjectId(object: Message): ID { + return object.id + } + + override getObjectDate(object: Message): Date { + return object.created + } + + override async onEvent(event: BroadcastEvent): Promise { + switch (event.type) { + case EventType.MessageCreated: + return await this.onCreateMessageEvent(event) + case EventType.MessageRemoved: + return await this.onRemoveMessageEvent(event) + case EventType.PatchCreated: + return await this.onCreatePatchEvent(event) + case EventType.ReactionCreated: + return await this.onCreateReactionEvent(event) + case EventType.ReactionRemoved: + return await this.onRemoveReactionEvent(event) + case EventType.AttachmentCreated: + return await this.onCreateAttachmentEvent(event) + case EventType.AttachmentRemoved: + return await this.onRemoveAttachmentEvent(event) + } + } + + async onCreateMessageEvent(event: MessageCreatedEvent): Promise { + if (this.result instanceof Promise) { + this.result = await this.result + } + + const message = { + ...event.message, + edited: new Date(event.message.edited), + created: new Date(event.message.created) + } + const exists = this.result.get(message.id) + + if (exists !== undefined) return + if (!this.match(message, event.card)) return + + if (this.result.isTail()) { + if (this.params.sort === SortOrder.Asc) { + this.result.push(message) + } else { + this.result.unshift(message) + } + await this.notify() + } + } + + private match(message: Message, card: CardID): boolean { + if (this.params.id != null && this.params.id !== message.id) { + return false + } + if (this.params.card != null && this.params.card !== card) { + return false + } + return true + } + + private async onCreatePatchEvent(event: PatchCreatedEvent): Promise { + if (this.result instanceof Promise) { + this.result = await this.result + } + + const patch = { + ...event.patch, + created: new Date(event.patch.created) + } + + const message = this.result.get(patch.message) + + if (message === undefined) return + + if (message.created < patch.created) { + this.result.update(this.applyPatch(message, patch)) + await this.notify() + } + } + + private async onRemoveMessageEvent(event: MessageRemovedEvent): Promise { + if (this.result instanceof Promise) { + this.result = await this.result + } + + const deleted = this.result.delete(event.message) + + if (deleted !== undefined) { + await this.notify() + } + } + + private async onCreateReactionEvent(event: ReactionCreatedEvent): Promise { + if (this.result instanceof Promise) { + this.result = await this.result + } + + const reaction = { + ...event.reaction, + created: new Date(event.reaction.created) + } + const message = this.result.get(reaction.message) + if (message === undefined) return + + message.reactions.push(reaction) + this.result.update(message) + await this.notify() + } + + private async onRemoveReactionEvent(event: ReactionRemovedEvent): Promise { + if (this.result instanceof Promise) { + this.result = await this.result + } + + const message = this.result.get(event.message) + if (message === undefined) return + + const reactions = message.reactions.filter((it) => it.reaction !== event.reaction && it.creator !== event.creator) + if (reactions.length === message.reactions.length) return + + const updated = { + ...message, + reactions + } + this.result.update(updated) + await this.notify() + } + + private async onCreateAttachmentEvent(event: AttachmentCreatedEvent): Promise { + if (this.result instanceof Promise) { + this.result = await this.result + } + + const attachment = { + ...event.attachment, + created: new Date(event.attachment.created) + } + const message = this.result.get(attachment.message) + if (message === undefined) return + + message.attachments.push(attachment) + this.result.update(message) + await this.notify() + } + + private async onRemoveAttachmentEvent(event: AttachmentRemovedEvent): Promise { + if (this.result instanceof Promise) { + this.result = await this.result + } + + const message = this.result.get(event.message) + if (message === undefined) return + + const attachments = message.attachments.filter((it) => it.card !== event.card) + if (attachments.length === message.attachments.length) return + + const updated = { + ...message, + attachments + } + this.result.update(updated) + await this.notify() + } + + private applyPatch(message: Message, patch: Patch): Message { + return { + ...message, + content: patch.content, + creator: patch.creator, + created: patch.created + } + } +} diff --git a/packages/query/src/notifications/query.ts b/packages/query/src/notifications/query.ts new file mode 100644 index 0000000000..8e9d39cfe3 --- /dev/null +++ b/packages/query/src/notifications/query.ts @@ -0,0 +1,129 @@ +import { + type FindNotificationsParams, + SortOrder, + type Notification, + type ID, +} from '@communication/types' +import { + type NotificationCreatedEvent, + EventType, + type BroadcastEvent, + type NotificationContextRemovedEvent, + type NotificationRemovedEvent, + type NotificationContextUpdatedEvent, +} from '@communication/sdk-types' + +import {BaseQuery} from '../query.ts'; + +export class NotificationQuery extends BaseQuery { + override async find(params: FindNotificationsParams): Promise { + return this.client.findNotifications(params, this.id) + } + + override getObjectId(object: Notification): ID { + return object.message.id + } + + override getObjectDate(object: Notification): Date { + return object.message.created + } + + override async onEvent(event: BroadcastEvent): Promise { + switch (event.type) { + case EventType.NotificationCreated: + return await this.onCreateNotificationEvent(event) + case EventType.NotificationRemoved: + return await this.onRemoveNotificationEvent(event) + case EventType.NotificationContextUpdated: + return await this.onUpdateNotificationContextEvent(event) + case EventType.NotificationContextRemoved: + return await this.onRemoveNotificationContextEvent(event) + } + } + + async onCreateNotificationEvent(event: NotificationCreatedEvent): Promise { + if (this.result instanceof Promise) { + this.result = await this.result + } + + const exists = this.result.get(event.notification.message.id) + if (exists !== undefined) return + + if (this.params.message != null && this.params.message !== event.notification.message.id) return + if (this.params.context != null && this.params.context !== event.notification.context) return + + if (this.result.isTail()) { + if (this.params.sort === SortOrder.Asc) { + this.result.push(event.notification) + } else { + this.result.unshift(event.notification) + } + await this.notify() + } + } + + + private async onUpdateNotificationContextEvent(event: NotificationContextUpdatedEvent): Promise { + if (this.result instanceof Promise) { + this.result = await this.result + } + + if (this.params.context != null && this.params.context !== event.context) return + if (event.update.lastView === undefined && event.update.archivedFrom === undefined) return + + const toUpdate = this.params.context === event.context ? + this.result.getResult() + : this.result.getResult().filter(it => it.context === event.context) + if (toUpdate.length === 0) return + + for (const notification of toUpdate) { + this.result.update({ + ...notification, + ...event.update.lastView !== undefined ? { + read: event.update.lastView < notification.message.created + } : {}, + ...event.update.archivedFrom !== undefined ? { + archived: event.update.archivedFrom < notification.message.created + } : {} + }) + } + } + + private async onRemoveNotificationEvent(event: NotificationRemovedEvent): Promise { + if (this.result instanceof Promise) { + this.result = await this.result + } + + const deleted = this.result.delete(event.message) + + if (deleted !== undefined) { + await this.notify() + } + } + + private async onRemoveNotificationContextEvent(event: NotificationContextRemovedEvent): Promise { + if (this.result instanceof Promise) { + this.result = await this.result + } + + if (this.params.context != null && this.params.context !== event.context) return + + if (event.context === this.params.context) { + if (this.result.length === 0) return + this.result.deleteAll() + this.result.setHead(true) + this.result.setTail(true) + await this.notify() + } else { + const toRemove = this.result.getResult().filter(it => it.context === event.context) + if (toRemove.length === 0) return + + for (const notification of toRemove) { + this.result.delete(notification.message.id) + } + await this.notify() + } + + } + +} diff --git a/packages/query/src/query.ts b/packages/query/src/query.ts new file mode 100644 index 0000000000..bec8fc200f --- /dev/null +++ b/packages/query/src/query.ts @@ -0,0 +1,198 @@ +import { Direction, type ID, SortOrder } from '@communication/types' +import { type BroadcastEvent, type QueryCallback, type Client } from '@communication/sdk-types' + +import { QueryResult } from './result' +import { defaultQueryParams, type FindParams, type Query, type QueryId } from './types' +import { WindowImpl } from './window' + +export class BaseQuery implements Query { + protected result: QueryResult | Promise> + private forward: Promise | T[] = [] + private backward: Promise | T[] = [] + + constructor( + protected readonly client: Client, + public readonly id: QueryId, + public readonly params: P, + private callback?: QueryCallback, + initialResult?: QueryResult + ) { + if (initialResult !== undefined) { + this.result = initialResult + void this.notify() + } else { + const limit = this.params.limit ?? defaultQueryParams.limit + const findParams = { + ...this.params, + excluded: this.params.excluded ?? defaultQueryParams.excluded, + direction: this.params.direction ?? defaultQueryParams.direction, + sort: this.params.sort ?? defaultQueryParams.sort, + limit: limit + 1 + } + + const findPromise = this.find(findParams) + this.result = findPromise.then((res) => { + const isTail = params.from ? res.length <= limit : params.sort === SortOrder.Desc + const isHead = params.from === undefined && params.sort === SortOrder.Asc + if (!isTail) { + res.pop() + } + const qResult = new QueryResult(res, this.getObjectId) + qResult.setTail(isTail) + qResult.setHead(isHead) + + return qResult + }) + this.result + .then(async () => { + await this.notify() + }) + .catch((err: any) => { + console.error('Failed to update Live query: ', err) + }) + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async find(params: FindParams): Promise { + /*Implement in subclass*/ + return [] as T[] + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected getObjectId(object: T): ID { + /*Implement in subclass*/ + return '' as ID + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected getObjectDate(object: T): Date { + /*Implement in subclass*/ + return new Date(0) as Date + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async onEvent(event: BroadcastEvent): Promise { + /*Implement in subclass*/ + } + + setCallback(callback: QueryCallback): void { + this.callback = callback + void this.notify() + } + + removeCallback(): void { + this.callback = () => {} + } + + protected async notify(): Promise { + if (this.callback === undefined) return + if (this.result instanceof Promise) { + this.result = await this.result + } + + const result = this.result.getResult() + const isTail = this.result.isTail() + const isHead = this.result.isHead() + + const window = new WindowImpl(result, isTail, isHead, this) + this.callback(window) + } + + async loadForward() { + if (this.result instanceof Promise) { + this.result = await this.result + } + if (this.forward instanceof Promise) { + this.forward = await this.forward + } + + if (this.result.isTail()) return + + const last = this.result.getLast() + if (last === undefined) return + + const limit = this.params.limit ?? defaultQueryParams.limit + const findParams: FindParams = { + ...this.params, + from: this.getObjectDate(last), + excluded: true, + direction: Direction.Forward, + limit: limit + 1, + sort: SortOrder.Asc + } + + const forward = this.find(findParams) + + this.forward = forward.then(async (res) => { + if (this.result instanceof Promise) { + this.result = await this.result + } + const isTail = res.length <= limit + if (!isTail) { + res.pop() + } + this.result.append(res) + this.result.setTail(isTail) + await this.notify() + return res + }) + } + + async loadBackward() { + if (this.result instanceof Promise) { + this.result = await this.result + } + if (this.backward instanceof Promise) { + this.backward = await this.backward + } + + if (this.result.isHead()) return + + const first = this.params.sort === SortOrder.Asc ? this.result.getFirst() : this.result.getLast() + if (first === undefined) return + + const limit = this.params.limit ?? defaultQueryParams.limit + const findParams: FindParams = { + ...this.params, + from: this.getObjectDate(first), + excluded: true, + direction: Direction.Backward, + limit: limit + 1, + sort: SortOrder.Desc + } + + const backward = this.find(findParams) + this.backward = backward.then(async (res) => { + if (this.result instanceof Promise) { + this.result = await this.result + } + const isHead = res.length <= limit + if (!isHead) { + res.pop() + } + + if (this.params.sort === SortOrder.Asc) { + const reversed = res.reverse() + this.result.prepend(reversed) + } else { + this.result.append(res) + } + this.result.setHead(isHead) + await this.notify() + return res + }) + } + + copyResult(): QueryResult | undefined { + if (this.result instanceof Promise) { + return undefined + } + + return this.result.copy() + } + + async unsubscribe(): Promise { + await this.client.unsubscribeQuery(this.id) + } +} diff --git a/packages/query/src/result.ts b/packages/query/src/result.ts new file mode 100644 index 0000000000..36395f74d5 --- /dev/null +++ b/packages/query/src/result.ts @@ -0,0 +1,92 @@ +import type { ID } from '@communication/types' + +export class QueryResult { + private objectById: Map + + private tail: boolean = false + private head: boolean = false + + get length(): number { + return this.objectById.size + } + + constructor( + messages: T[], + private readonly getId: (it: T) => ID + ) { + this.objectById = new Map(messages.map((it) => [getId(it), it])) + } + + isTail(): boolean { + return this.tail + } + + isHead(): boolean { + return this.head + } + + setHead(head: boolean) { + this.head = head + } + + setTail(tail: boolean) { + this.tail = tail + } + + getResult(): T[] { + return Array.from(this.objectById.values()) + } + + get(id: ID): Readonly | undefined { + return this.objectById.get(id) + } + + delete(id: ID): T | undefined { + const object = this.objectById.get(id) + this.objectById.delete(id) + return object + } + + deleteAll() { + this.objectById.clear() + } + + push(object: T): void { + this.objectById.set(this.getId(object), object) + } + + unshift(object: T): void { + this.objectById = new Map([[this.getId(object), object], ...this.objectById]) + } + + update(object: T): void { + this.objectById.set(this.getId(object), object) + } + + getFirst(): T | undefined { + return Array.from(this.objectById.values())[0] + } + + getLast(): T | undefined { + return Array.from(this.objectById.values())[this.objectById.size - 1] + } + + prepend(objects: T[]) { + const current = Array.from(this.objectById.entries()) + this.objectById = new Map([...objects.map<[ID, T]>((object) => [this.getId(object), object]), ...current]) + } + + append(objects: T[]) { + for (const object of objects) { + this.objectById.set(this.getId(object), object) + } + } + + copy(): QueryResult { + const copy = new QueryResult(Array.from(this.objectById.values()), this.getId) + + copy.setHead(this.head) + copy.setTail(this.tail) + return copy + } +} diff --git a/packages/query/src/types.ts b/packages/query/src/types.ts new file mode 100644 index 0000000000..895e21d3d9 --- /dev/null +++ b/packages/query/src/types.ts @@ -0,0 +1,33 @@ +import { type BroadcastEvent } from '@communication/sdk-types' +import { Direction, SortOrder, type Window } from '@communication/types' + +import { QueryResult } from './result.ts' + +export type QueryId = number + +export const defaultQueryParams = { + limit: 50, + excluded: false, + direction: Direction.Forward, + sort: SortOrder.Desc +} + +export type FindParams = Partial & { + from?: Date +} + +export interface Query { + readonly id: QueryId + readonly params: P + + onEvent(event: BroadcastEvent): Promise + + loadForward(): Promise + loadBackward(): Promise + + unsubscribe(): Promise + + setCallback(callback: (window: Window) => void): void + removeCallback(): void + copyResult(): QueryResult | undefined +} diff --git a/packages/query/src/window.ts b/packages/query/src/window.ts new file mode 100644 index 0000000000..508c2b5d89 --- /dev/null +++ b/packages/query/src/window.ts @@ -0,0 +1,34 @@ +import type { Window } from '@communication/types' + +import type { Query } from './types' + +export class WindowImpl implements Window { + constructor( + private readonly result: T[], + private readonly isTail: boolean, + private readonly isHead: boolean, + private readonly query: Query + ) {} + + getResult(): T[] { + return this.result + } + + async loadNextPage(): Promise { + if (!this.hasNextPage()) return + await this.query.loadForward() + } + + async loadPrevPage(): Promise { + if (!this.hasPrevPage()) return + await this.query.loadBackward() + } + + hasNextPage(): boolean { + return !this.isTail + } + + hasPrevPage(): boolean { + return !this.isHead + } +} diff --git a/packages/query/tsconfig.json b/packages/query/tsconfig.json new file mode 100644 index 0000000000..3ae07cd3fa --- /dev/null +++ b/packages/query/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/packages/sdk-types/src/client.ts b/packages/sdk-types/src/client.ts index dce6d468e9..af3edce94b 100644 --- a/packages/sdk-types/src/client.ts +++ b/packages/sdk-types/src/client.ts @@ -42,4 +42,3 @@ export interface Client { unsubscribeQuery(id: number): Promise close(): void } - diff --git a/packages/sdk-types/src/index.ts b/packages/sdk-types/src/index.ts index 38c4587b9e..01596627a6 100644 --- a/packages/sdk-types/src/index.ts +++ b/packages/sdk-types/src/index.ts @@ -1,4 +1,5 @@ export * from './db' export * from './event' export * from './ws' -export * from './client' \ No newline at end of file +export * from './client' +export * from './query' diff --git a/packages/sdk-types/src/query.ts b/packages/sdk-types/src/query.ts new file mode 100644 index 0000000000..c09a164af5 --- /dev/null +++ b/packages/sdk-types/src/query.ts @@ -0,0 +1,6 @@ +import type { Message, Window, Notification } from '@communication/types' + +export type QueryCallback = (window: Window) => void + +export type MessagesQueryCallback = QueryCallback +export type NotificationsQueryCallback = QueryCallback