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