diff --git a/dev/client-resources/src/connection.ts b/dev/client-resources/src/connection.ts index b69716306c..c241afdc20 100644 --- a/dev/client-resources/src/connection.ts +++ b/dev/client-resources/src/connection.ts @@ -14,10 +14,9 @@ // import { Class, ClientConnection, Doc, DocumentQuery, FindOptions, FindResult, Ref, ServerStorage, Tx, TxHander, TxResult, DOMAIN_TX, MeasureMetricsContext } from '@anticrm/core' -import { createInMemoryAdapter, createInMemoryTxAdapter } from '@anticrm/dev-storage' +import { createInMemoryTxAdapter } from '@anticrm/dev-storage' import { protoDeserialize, protoSerialize, setMetadata } from '@anticrm/platform' -import type { DbConfiguration } from '@anticrm/server-core' -import { createServerStorage, FullTextAdapter, IndexedDoc } from '@anticrm/server-core' +import { createInMemoryAdapter, createServerStorage, DbConfiguration, FullTextAdapter, IndexedDoc } from '@anticrm/server-core' import devmodel from '@anticrm/devmodel' class ServerStorageWrapper implements ClientConnection { diff --git a/dev/server/src/server.ts b/dev/server/src/server.ts index 0f53aa22ce..53df1acda3 100644 --- a/dev/server/src/server.ts +++ b/dev/server/src/server.ts @@ -16,8 +16,8 @@ import type { Doc, Ref, TxResult } from '@anticrm/core' import { DOMAIN_TX, MeasureMetricsContext } from '@anticrm/core' -import { createInMemoryAdapter, createInMemoryTxAdapter } from '@anticrm/dev-storage' -import { createPipeline, DbConfiguration, FullTextAdapter, IndexedDoc } from '@anticrm/server-core' +import { createInMemoryTxAdapter } from '@anticrm/dev-storage' +import { createInMemoryAdapter, createPipeline, DbConfiguration, FullTextAdapter, IndexedDoc } from '@anticrm/server-core' import { start as startJsonRpc } from '@anticrm/server-ws' class NullFullTextAdapter implements FullTextAdapter { diff --git a/dev/storage/src/storage.ts b/dev/storage/src/storage.ts index da19b00acc..c37c8d834e 100644 --- a/dev/storage/src/storage.ts +++ b/dev/storage/src/storage.ts @@ -1,5 +1,5 @@ // -// Copyright © 2020 Anticrm Platform Contributors. +// Copyright © 2022 Hardcore Engineering Inc. // // Licensed under the Eclipse Public License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. You may @@ -13,11 +13,10 @@ // limitations under the License. // -import type { Tx, Ref, Doc, Class, DocumentQuery, FindResult, FindOptions, TxResult } from '@anticrm/core' -import { ModelDb, TxDb, Hierarchy } from '@anticrm/core' -import type { DbAdapter, TxAdapter } from '@anticrm/server-core' - +import type { Class, Doc, DocumentQuery, FindOptions, FindResult, Ref, Tx, TxResult } from '@anticrm/core' +import { Hierarchy, TxDb } from '@anticrm/core' import builder from '@anticrm/model-all' +import type { TxAdapter } from '@anticrm/server-core' class InMemoryTxAdapter implements TxAdapter { private readonly txdb: TxDb @@ -47,40 +46,9 @@ class InMemoryTxAdapter implements TxAdapter { async close (): Promise {} } -class InMemoryAdapter implements DbAdapter { - private readonly modeldb: ModelDb - - constructor (hierarchy: Hierarchy) { - this.modeldb = new ModelDb(hierarchy) - } - - async findAll (_class: Ref>, query: DocumentQuery, options?: FindOptions): Promise> { - return await this.modeldb.findAll(_class, query, options) - } - - async tx (tx: Tx): Promise { - return await this.modeldb.tx(tx) - } - - async init (model: Tx[]): Promise { - for (const tx of model) { - await this.modeldb.tx(tx) - } - } - - async close (): Promise {} -} - /** * @public */ export async function createInMemoryTxAdapter (hierarchy: Hierarchy, url: string, workspace: string): Promise { return new InMemoryTxAdapter(hierarchy) } - -/** - * @public - */ -export async function createInMemoryAdapter (hierarchy: Hierarchy, url: string, db: string): Promise { - return new InMemoryAdapter(hierarchy) -} diff --git a/models/core/src/index.ts b/models/core/src/index.ts index eb8237c338..dfb253451a 100644 --- a/models/core/src/index.ts +++ b/models/core/src/index.ts @@ -33,6 +33,7 @@ import { TVersion } from './core' import { TAccount, TSpace } from './security' +import { TUserStatus } from './transient' import { TTx, TTxBulkWrite, @@ -83,6 +84,7 @@ export function createModel (builder: Builder): void { TVersion, TTypeNumber, TTypeIntlString, - TPluginConfiguration + TPluginConfiguration, + TUserStatus ) } diff --git a/models/core/src/transient.ts b/models/core/src/transient.ts new file mode 100644 index 0000000000..8dc86c1388 --- /dev/null +++ b/models/core/src/transient.ts @@ -0,0 +1,24 @@ +// +// Copyright © 2022 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { DOMAIN_TRANSIENT, UserStatus } from '@anticrm/core' +import { Model } from '@anticrm/model' +import core from './component' +import { TDoc } from './core' + +@Model(core.class.UserStatus, core.class.Doc, DOMAIN_TRANSIENT) +export class TUserStatus extends TDoc implements UserStatus { + online!: boolean +} diff --git a/packages/core/src/classes.ts b/packages/core/src/classes.ts index 4812c8c03a..a54f1e811b 100644 --- a/packages/core/src/classes.ts +++ b/packages/core/src/classes.ts @@ -227,6 +227,11 @@ export interface ArrOf extends Type { */ export const DOMAIN_MODEL = 'model' as Domain +/** + * @public + */ +export const DOMAIN_TRANSIENT = 'transient' as Domain + // S P A C E /** @@ -247,6 +252,13 @@ export interface Account extends Doc { email: string } +/** + * @public + */ +export interface UserStatus extends Doc { + online: boolean +} + /** * @public */ diff --git a/packages/core/src/component.ts b/packages/core/src/component.ts index 65460267ec..39d8912a5a 100644 --- a/packages/core/src/component.ts +++ b/packages/core/src/component.ts @@ -15,7 +15,7 @@ import type { IntlString, Plugin, StatusCode } from '@anticrm/platform' import { plugin } from '@anticrm/platform' import { Mixin, Version } from '.' -import type { Account, AnyAttribute, ArrOf, AttachedDoc, Class, Collection, Doc, Interface, Obj, PluginConfiguration, PropertyType, Ref, RefTo, Space, Timestamp, Type } from './classes' +import type { Account, AnyAttribute, ArrOf, AttachedDoc, Class, Collection, Doc, Interface, Obj, PluginConfiguration, PropertyType, Ref, RefTo, Space, Timestamp, Type, UserStatus } from './classes' import type { Tx, TxBulkWrite, TxCollectionCUD, TxCreateDoc, TxCUD, TxMixin, TxPutBag, TxRemoveDoc, TxUpdateDoc } from './tx' /** @@ -55,7 +55,8 @@ export default plugin(coreId, { Collection: '' as Ref>>, Bag: '' as Ref>>>, Version: '' as Ref>, - PluginConfiguration: '' as Ref> + PluginConfiguration: '' as Ref>, + UserStatus: '' as Ref> }, space: { Tx: '' as Ref, diff --git a/server-plugins/chunter-resources/src/index.ts b/server-plugins/chunter-resources/src/index.ts index 6653fdc2ce..7b2b1f3e5e 100644 --- a/server-plugins/chunter-resources/src/index.ts +++ b/server-plugins/chunter-resources/src/index.ts @@ -109,6 +109,10 @@ export async function CommentDelete (tx: Tx, control: TriggerControl): Promise) const comments = await control.findAll(chunter.class.ThreadMessage, { @@ -149,7 +153,7 @@ export async function MessageCreate (tx: Tx, control: TriggerControl): Promise(channel._class, channel.space, channel._id, { lastMessage: message.createOn }) @@ -173,13 +177,17 @@ export async function MessageDelete (tx: Tx, control: TriggerControl): Promise) const channel = (await control.findAll(chunter.class.ChunterSpace, { _id: message.space }, { limit: 1 }))[0] - if (channel.lastMessage === message.createOn) { + if (channel?.lastMessage === message.createOn) { const messages = await control.findAll(chunter.class.Message, { attachedTo: channel._id }) diff --git a/server-plugins/notification-resources/src/index.ts b/server-plugins/notification-resources/src/index.ts index 1c38e0b2ca..5e898615c2 100644 --- a/server-plugins/notification-resources/src/index.ts +++ b/server-plugins/notification-resources/src/index.ts @@ -109,7 +109,7 @@ export async function UpdateLastView (tx: Tx, control: TriggerControl): Promise< case core.class.TxMixin: { const tx = actualTx as TxCUD const doc = (await control.findAll(tx.objectClass, { _id: tx.objectId }, { limit: 1 }))[0] - if (!control.hierarchy.isDerived(doc._class, core.class.AttachedDoc)) { + if (doc !== undefined && !control.hierarchy.isDerived(doc._class, core.class.AttachedDoc)) { const resTx = await getUpdateLastViewTx(control.findAll, doc._id, doc._class, tx.modifiedOn, tx.modifiedBy) if (resTx !== undefined) { result.push(resTx) diff --git a/server/core/src/adapter.ts b/server/core/src/adapter.ts new file mode 100644 index 0000000000..e47b0cdbac --- /dev/null +++ b/server/core/src/adapter.ts @@ -0,0 +1,91 @@ +// +// Copyright © 2022 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + Class, + Doc, + DocumentQuery, + FindOptions, + FindResult, + Hierarchy, + ModelDb, + Ref, + Tx, + TxResult +} from '@anticrm/core' + +/** + * @public + */ +export interface DbAdapter { + /** + * Method called after hierarchy is ready to use. + */ + init: (model: Tx[]) => Promise + close: () => Promise + findAll: (_class: Ref>, query: DocumentQuery, options?: FindOptions) => Promise> + tx: (tx: Tx) => Promise +} + +/** + * @public + */ +export interface TxAdapter extends DbAdapter { + getModel: () => Promise +} + +/** + * @public + */ +export type DbAdapterFactory = (hierarchy: Hierarchy, url: string, db: string, modelDb: ModelDb) => Promise + +/** + * @public + */ +export interface DbAdapterConfiguration { + factory: DbAdapterFactory + url: string +} + +class InMemoryAdapter implements DbAdapter { + private readonly modeldb: ModelDb + + constructor (hierarchy: Hierarchy) { + this.modeldb = new ModelDb(hierarchy) + } + + async findAll (_class: Ref>, query: DocumentQuery, options?: FindOptions): Promise> { + return await this.modeldb.findAll(_class, query, options) + } + + async tx (tx: Tx): Promise { + return await this.modeldb.tx(tx) + } + + async init (model: Tx[]): Promise { + for (const tx of model) { + await this.modeldb.tx(tx) + } + } + + async close (): Promise {} +} + +/** + * @public + */ +export async function createInMemoryAdapter (hierarchy: Hierarchy, url: string, db: string): Promise { + return new InMemoryAdapter(hierarchy) +} diff --git a/server/core/src/index.ts b/server/core/src/index.ts index 6a42e27d8e..27d7bcd011 100644 --- a/server/core/src/index.ts +++ b/server/core/src/index.ts @@ -14,6 +14,7 @@ // limitations under the License. // +export * from './adapter' export * from './types' export * from './fulltext' export * from './storage' diff --git a/server/core/src/pipeline.ts b/server/core/src/pipeline.ts index 2df640166a..bbab15ca1e 100644 --- a/server/core/src/pipeline.ts +++ b/server/core/src/pipeline.ts @@ -13,9 +13,20 @@ // limitations under the License. // -import { Class, Doc, DocumentQuery, FindOptions, FindResult, Ref, ServerStorage, Tx, TxResult } from '@anticrm/core' -import { Pipeline, Middleware, MiddlewareCreator, SessionContext } from './types' +import { + Class, + Doc, + DocumentQuery, + FindOptions, + FindResult, + ModelDb, + Ref, + ServerStorage, + Tx, + TxResult +} from '@anticrm/core' import { createServerStorage, DbConfiguration } from './storage' +import { Middleware, MiddlewareCreator, Pipeline, SessionContext } from './types' /** * @public @@ -27,8 +38,10 @@ export async function createPipeline (conf: DbConfiguration, constructors: Middl class TPipeline implements Pipeline { private readonly head: Middleware | undefined + readonly modelDb: ModelDb constructor (private readonly storage: ServerStorage, constructors: MiddlewareCreator[]) { this.head = this.buildChain(constructors) + this.modelDb = storage.modelDb } private buildChain (constructors: MiddlewareCreator[]): Middleware | undefined { @@ -40,13 +53,14 @@ class TPipeline implements Pipeline { return current } - async findAll ( + async findAll( ctx: SessionContext, _class: Ref>, query: DocumentQuery, options?: FindOptions ): Promise> { - const [session, resClass, resQuery, resOptions] = this.head === undefined ? [ctx, _class, query, options] : await this.head.findAll(ctx, _class, query, options) + const [session, resClass, resQuery, resOptions] = + this.head === undefined ? [ctx, _class, query, options] : await this.head.findAll(ctx, _class, query, options) return await this.storage.findAll(session, resClass, resQuery, resOptions) } diff --git a/server/core/src/storage.ts b/server/core/src/storage.ts index d0c50f1c69..0d3036ad3e 100644 --- a/server/core/src/storage.ts +++ b/server/core/src/storage.ts @@ -43,44 +43,12 @@ import core, { } from '@anticrm/core' import { getResource } from '@anticrm/platform' import type { Client as MinioClient } from 'minio' +import { DbAdapter, DbAdapterConfiguration, TxAdapter } from './adapter' import { FullTextIndex } from './fulltext' import serverCore from './plugin' import { Triggers } from './triggers' import type { FullTextAdapter, FullTextAdapterFactory, ObjectDDParticipant } from './types' -/** - * @public - */ -export interface DbAdapter { - /** - * Method called after hierarchy is ready to use. - */ - init: (model: Tx[]) => Promise - close: () => Promise - findAll: (_class: Ref>, query: DocumentQuery, options?: FindOptions) => Promise> - tx: (tx: Tx) => Promise -} - -/** - * @public - */ -export interface TxAdapter extends DbAdapter { - getModel: () => Promise -} - -/** - * @public - */ -export type DbAdapterFactory = (hierarchy: Hierarchy, url: string, db: string, modelDb: ModelDb) => Promise - -/** - * @public - */ -export interface DbAdapterConfiguration { - factory: DbAdapterFactory - url: string -} - /** * @public */ diff --git a/server/core/src/types.ts b/server/core/src/types.ts index a22cbed1c1..10243c1f7e 100644 --- a/server/core/src/types.ts +++ b/server/core/src/types.ts @@ -13,7 +13,24 @@ // limitations under the License. // -import type { Account, Class, Doc, DocumentQuery, FindOptions, FindResult, MeasureContext, ModelDb, Obj, Ref, ServerStorage, Space, Storage, Timestamp, Tx, TxResult } from '@anticrm/core' +import type { + Account, + Class, + Doc, + DocumentQuery, + FindOptions, + FindResult, + MeasureContext, + ModelDb, + Obj, + Ref, + ServerStorage, + Space, + Storage, + Timestamp, + Tx, + TxResult +} from '@anticrm/core' import { Hierarchy, TxFactory } from '@anticrm/core' import type { Resource } from '@anticrm/platform' import type { Client as MinioClient } from 'minio' @@ -30,7 +47,12 @@ export interface SessionContext extends MeasureContext { */ export interface Middleware { tx: (ctx: SessionContext, tx: Tx) => Promise - findAll: (ctx: SessionContext, _class: Ref>, query: DocumentQuery, options?: FindOptions) => Promise> + findAll: ( + ctx: SessionContext, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ) => Promise> } /** @@ -46,12 +68,18 @@ export type TxMiddlewareResult = [SessionContext, Tx, string | undefined] /** * @public */ -export type FindAllMiddlewareResult = [SessionContext, Ref>, DocumentQuery, FindOptions | undefined] +export type FindAllMiddlewareResult = [ + SessionContext, + Ref>, + DocumentQuery, + FindOptions | undefined +] /** * @public */ export interface Pipeline { + modelDb: ModelDb findAll: ( ctx: SessionContext, _class: Ref>, @@ -112,7 +140,12 @@ export interface FullTextAdapter { index: (doc: IndexedDoc) => Promise update: (id: Ref, update: Record) => Promise remove: (id: Ref) => Promise - search: (_classes: Ref>[], search: DocumentQuery, size: number | undefined, from?: number) => Promise + search: ( + _classes: Ref>[], + search: DocumentQuery, + size: number | undefined, + from?: number + ) => Promise close: () => Promise } @@ -125,7 +158,12 @@ export type FullTextAdapterFactory = (url: string, workspace: string) => Promise * @public */ export interface WithFind { - findAll: (ctx: MeasureContext, clazz: Ref>, query: DocumentQuery, options?: FindOptions) => Promise> + findAll: ( + ctx: MeasureContext, + clazz: Ref>, + query: DocumentQuery, + options?: FindOptions + ) => Promise> } /** @@ -134,5 +172,15 @@ export interface WithFind { */ export interface ObjectDDParticipant extends Class { // Collect more items to be deleted if parent document is deleted. - collectDocs: Resource<(doc: Doc, hiearachy: Hierarchy, findAll: (clazz: Ref>, query: DocumentQuery, options?: FindOptions) => Promise>) => Promise> + collectDocs: Resource< + ( + doc: Doc, + hiearachy: Hierarchy, + findAll: ( + clazz: Ref>, + query: DocumentQuery, + options?: FindOptions + ) => Promise> + ) => Promise + > } diff --git a/server/server/src/server.ts b/server/server/src/server.ts index 0be704598d..dd6e403e74 100644 --- a/server/server/src/server.ts +++ b/server/server/src/server.ts @@ -18,6 +18,7 @@ import { Doc, DocumentQuery, DOMAIN_MODEL, + DOMAIN_TRANSIENT, DOMAIN_TX, FindOptions, FindResult, @@ -34,7 +35,7 @@ import { serverAttachmentId } from '@anticrm/server-attachment' import { serverCalendarId } from '@anticrm/server-calendar' import { serverChunterId } from '@anticrm/server-chunter' import { serverContactId } from '@anticrm/server-contact' -import { createPipeline, DbAdapter, DbConfiguration, MiddlewareCreator } from '@anticrm/server-core' +import { createInMemoryAdapter, createPipeline, DbAdapter, DbConfiguration, MiddlewareCreator } from '@anticrm/server-core' import { serverGmailId } from '@anticrm/server-gmail' import { serverInventoryId } from '@anticrm/server-inventory' import { serverLeadId } from '@anticrm/server-lead' @@ -113,6 +114,7 @@ export function start ( const conf: DbConfiguration = { domains: { [DOMAIN_TX]: 'MongoTx', + [DOMAIN_TRANSIENT]: 'InMemory', [DOMAIN_MODEL]: 'Null' }, defaultAdapter: 'Mongo', @@ -128,6 +130,10 @@ export function start ( Null: { factory: createNullAdapter, url: '' + }, + InMemory: { + factory: createInMemoryAdapter, + url: '' } }, fulltextAdapter: { diff --git a/server/ws/src/__tests__/minmodel.ts b/server/ws/src/__tests__/minmodel.ts new file mode 100644 index 0000000000..9fd015fe64 --- /dev/null +++ b/server/ws/src/__tests__/minmodel.ts @@ -0,0 +1,198 @@ +// +// Copyright © 2020 Anticrm Platform Contributors. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { Account, Arr, Class, Data, Doc, Mixin, Obj, Ref, TxCreateDoc, TxCUD } from '@anticrm/core' +import core, { AttachedDoc, ClassifierKind, DOMAIN_MODEL, DOMAIN_TX, TxFactory } from '@anticrm/core' +import type { IntlString, Plugin } from '@anticrm/platform' +import { plugin } from '@anticrm/platform' + +export const txFactory = new TxFactory(core.account.System) + +export function createClass (_class: Ref>, attributes: Data>): TxCreateDoc { + return txFactory.createTxCreateDoc(core.class.Class, core.space.Model, attributes, _class) +} + +/** + * @public + */ +export function createDoc ( + _class: Ref>, + attributes: Data, + id?: Ref, + modifiedBy?: Ref +): TxCreateDoc { + const result = txFactory.createTxCreateDoc(_class, core.space.Model, attributes, id) + if (modifiedBy !== undefined) { + result.modifiedBy = modifiedBy + } + return result +} + +/** + * @public + */ +export interface TestMixin extends Doc { + arr: Arr +} + +/** + * @public + */ +export interface AttachedComment extends AttachedDoc { + message: string +} + +/** + * @public + */ +export const test = plugin('test' as Plugin, { + mixin: { + TestMixin: '' as Ref> + }, + class: { + TestComment: '' as Ref> + } +}) + +/** + * @public + * Generate minimal model for testing purposes. + * @returns R + */ +export function genMinModel (): TxCUD[] { + const txes = [] + // Fill Tx'es with basic model classes. + txes.push(createClass(core.class.Obj, { label: 'Obj' as IntlString, kind: ClassifierKind.CLASS })) + txes.push( + createClass(core.class.Doc, { label: 'Doc' as IntlString, extends: core.class.Obj, kind: ClassifierKind.CLASS }) + ) + txes.push( + createClass(core.class.AttachedDoc, { + label: 'AttachedDoc' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.MIXIN + }) + ) + txes.push( + createClass(core.class.Class, { + label: 'Class' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_MODEL + }) + ) + txes.push( + createClass(core.class.Space, { + label: 'Space' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_MODEL + }) + ) + txes.push( + createClass(core.class.Account, { + label: 'Account' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_MODEL + }) + ) + + txes.push( + createClass(core.class.Tx, { + label: 'Tx' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_TX + }) + ) + txes.push( + createClass(core.class.TxCUD, { + label: 'TxCUD' as IntlString, + extends: core.class.Tx, + kind: ClassifierKind.CLASS, + domain: DOMAIN_TX + }) + ) + txes.push( + createClass(core.class.TxCreateDoc, { + label: 'TxCreateDoc' as IntlString, + extends: core.class.TxCUD, + kind: ClassifierKind.CLASS + }) + ) + txes.push( + createClass(core.class.TxUpdateDoc, { + label: 'TxUpdateDoc' as IntlString, + extends: core.class.TxCUD, + kind: ClassifierKind.CLASS + }) + ) + txes.push( + createClass(core.class.TxRemoveDoc, { + label: 'TxRemoveDoc' as IntlString, + extends: core.class.TxCUD, + kind: ClassifierKind.CLASS + }) + ) + txes.push( + createClass(core.class.TxCollectionCUD, { + label: 'TxCollectionCUD' as IntlString, + extends: core.class.TxCUD, + kind: ClassifierKind.CLASS + }) + ) + + txes.push( + createClass(test.mixin.TestMixin, { + label: 'TestMixin' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.MIXIN + }) + ) + + txes.push( + createClass(test.class.TestComment, { + label: 'TestComment' as IntlString, + extends: core.class.AttachedDoc, + kind: ClassifierKind.CLASS + }) + ) + + const u1 = 'User1' as Ref + const u2 = 'User2' as Ref + txes.push( + createDoc(core.class.Account, { email: 'user1@site.com' }, u1), + createDoc(core.class.Account, { email: 'user2@site.com' }, u2), + createDoc(core.class.Space, { + name: 'Sp1', + description: '', + private: false, + archived: false, + members: [u1, u2] + }) + ) + + txes.push( + createDoc(core.class.Space, { + name: 'Sp2', + description: '', + private: false, + archived: false, + members: [u1] + }) + ) + return txes +} diff --git a/server/ws/src/__tests__/server.test.ts b/server/ws/src/__tests__/server.test.ts index db2ec23d6a..73509e4d43 100644 --- a/server/ws/src/__tests__/server.test.ts +++ b/server/ws/src/__tests__/server.test.ts @@ -19,7 +19,7 @@ import { start, disableLogging } from '../server' import { generateToken } from '@anticrm/server-token' import WebSocket from 'ws' -import type { +import { Doc, Ref, Class, @@ -27,17 +27,35 @@ import type { FindOptions, FindResult, Tx, - TxResult + TxResult, + ModelDb, + MeasureMetricsContext, + toFindResult, + Hierarchy } from '@anticrm/core' -import { MeasureMetricsContext, toFindResult } from '@anticrm/core' import { SessionContext } from '@anticrm/server-core' +import { genMinModel } from './minmodel' describe('server', () => { disableLogging() + async function getModelDb (): Promise { + const txes = genMinModel() + const hierarchy = new Hierarchy() + for (const tx of txes) { + hierarchy.tx(tx) + } + const modelDb = new ModelDb(hierarchy) + for (const tx of txes) { + await modelDb.tx(tx) + } + return modelDb + } + start( new MeasureMetricsContext('test', {}), async () => ({ + modelDb: await getModelDb(), findAll: async ( ctx: SessionContext, _class: Ref>, diff --git a/server/ws/src/server.ts b/server/ws/src/server.ts index faea629af3..a4f2f467bf 100644 --- a/server/ws/src/server.ts +++ b/server/ws/src/server.ts @@ -13,7 +13,19 @@ // limitations under the License. // -import { Class, Doc, DocumentQuery, FindOptions, FindResult, MeasureContext, Ref, Tx, TxResult } from '@anticrm/core' +import core, { + Class, + Doc, + DocumentQuery, + FindOptions, + FindResult, + MeasureContext, + ModelDb, + Ref, + Space, + Tx, TxFactory, + TxResult +} from '@anticrm/core' import { readRequest, Response, serialize, unknownError } from '@anticrm/platform' import type { Pipeline, SessionContext } from '@anticrm/server-core' import { decodeToken, Token } from '@anticrm/server-token' @@ -22,22 +34,36 @@ import WebSocket, { Server } from 'ws' let LOGGING_ENABLED = true -export function disableLogging (): void { LOGGING_ENABLED = false } +export function disableLogging (): void { + LOGGING_ENABLED = false +} class Session { + readonly modelDb: ModelDb + constructor ( private readonly manager: SessionManager, private readonly token: Token, private readonly pipeline: Pipeline - ) {} + ) { + this.modelDb = pipeline.modelDb + } getUser (): string { return this.token.email } - async ping (): Promise { console.log('ping'); return 'pong!' } + async ping (): Promise { + console.log('ping') + return 'pong!' + } - async findAll (ctx: MeasureContext, _class: Ref>, query: DocumentQuery, options?: FindOptions): Promise> { + async findAll( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { const context = ctx as SessionContext context.userEmail = this.token.email return await this.pipeline.findAll(context, _class, query, options) @@ -48,9 +74,9 @@ class Session { context.userEmail = this.token.email const [result, derived, target] = await this.pipeline.tx(context, tx) - this.manager.broadcast(this, this.token, { result: tx }, target) + this.manager.broadcast(this, this.token.workspace, { result: tx }, target) for (const dtx of derived) { - this.manager.broadcast(null, this.token, { result: dtx }, target) + this.manager.broadcast(null, this.token.workspace, { result: dtx }, target) } return result } @@ -64,10 +90,15 @@ interface Workspace { class SessionManager { private readonly workspaces = new Map() - async addSession (ws: WebSocket, token: Token, pipelineFactory: (ws: string) => Promise): Promise { + async addSession ( + ctx: MeasureContext, + ws: WebSocket, + token: Token, + pipelineFactory: (ws: string) => Promise + ): Promise { const workspace = this.workspaces.get(token.workspace) if (workspace === undefined) { - return await this.createWorkspace(pipelineFactory, token, ws) + return await this.createWorkspace(ctx, pipelineFactory, token, ws) } else { if (token.extra?.model === 'reload') { console.log('reloading workspace', JSON.stringify(token)) @@ -75,19 +106,56 @@ class SessionManager { // Drop all existing clients if (workspace.sessions.length > 0) { for (const s of workspace.sessions) { - this.close(s[1], token.workspace, 0, 'upgrade') + await this.close(ctx, s[1], token.workspace, 0, 'upgrade') } } - return await this.createWorkspace(pipelineFactory, token, ws) + return await this.createWorkspace(ctx, pipelineFactory, token, ws) } const session = new Session(this, token, workspace.pipeline) workspace.sessions.push([session, ws]) + await this.setStatus(ctx, session, true) return session } } - private async createWorkspace (pipelineFactory: (ws: string) => Promise, token: Token, ws: WebSocket): Promise { + private async setStatus (ctx: MeasureContext, session: Session, online: boolean): Promise { + try { + const user = ( + await session.modelDb.findAll( + core.class.Account, + { + email: session.getUser() + }, + { limit: 1 } + ) + )[0] + if (user === undefined) return + const status = (await session.findAll(ctx, core.class.UserStatus, { modifiedBy: user._id }, { limit: 1 }))[0] + const txFactory = new TxFactory(user._id) + if (status === undefined) { + const tx = txFactory.createTxCreateDoc(core.class.UserStatus, user._id as string as Ref, { + online + }) + tx.space = core.space.DerivedTx + await session.tx(ctx, tx) + } else if (status.online !== online) { + const tx = txFactory.createTxUpdateDoc(status._class, status.space, status._id, { + online + }) + tx.space = core.space.DerivedTx + await session.tx(ctx, tx) + } + } catch { + } + } + + private async createWorkspace ( + ctx: MeasureContext, + pipelineFactory: (ws: string) => Promise, + token: Token, + ws: WebSocket + ): Promise { const pipeline = await pipelineFactory(token.workspace) const session = new Session(this, token, pipeline) const workspace: Workspace = { @@ -95,26 +163,36 @@ class SessionManager { sessions: [[session, ws]] } this.workspaces.set(token.workspace, workspace) + await this.setStatus(ctx, session, true) return session } - close (ws: WebSocket, workspaceId: string, code: number, reason: string): void { + async close (ctx: MeasureContext, ws: WebSocket, workspaceId: string, code: number, reason: string): Promise { if (LOGGING_ENABLED) console.log(`closing websocket, code: ${code}, reason: ${reason}`) const workspace = this.workspaces.get(workspaceId) if (workspace === undefined) { console.error(new Error('internal: cannot find sessions')) return } - workspace.sessions = workspace.sessions.filter(session => session[1] !== ws) - if (workspace.sessions.length === 0) { - if (LOGGING_ENABLED) console.log('no sessions for workspace', workspaceId) - this.workspaces.delete(workspaceId) - workspace.pipeline.close().catch(err => console.error(err)) + const index = workspace.sessions.findIndex((p) => p[1] === ws) + if (index !== -1) { + const session = workspace.sessions[index] + workspace.sessions.splice(index, 1) + const user = session[0].getUser() + const another = workspace.sessions.findIndex((p) => p[0].getUser() === user) + if (another === -1) { + await this.setStatus(ctx, session[0], false) + } + if (workspace.sessions.length === 0) { + if (LOGGING_ENABLED) console.log('no sessions for workspace', workspaceId) + this.workspaces.delete(workspaceId) + workspace.pipeline.close().catch((err) => console.error(err)) + } } } - broadcast (from: Session | null, token: Token, resp: Response, target?: string): void { - const workspace = this.workspaces.get(token.workspace) + broadcast (from: Session | null, workspaceId: string, resp: Response, target?: string): void { + const workspace = this.workspaces.get(workspaceId) if (workspace === undefined) { console.error(new Error('internal: cannot find sessions')) return @@ -133,7 +211,12 @@ class SessionManager { } } -async function handleRequest (ctx: MeasureContext, service: S, ws: WebSocket, msg: string): Promise { +async function handleRequest ( + ctx: MeasureContext, + service: S, + ws: WebSocket, + msg: string +): Promise { const request = readRequest(msg) const f = (service as any)[request.method] try { @@ -156,7 +239,12 @@ async function handleRequest (ctx: MeasureContext, service: S * @param port - * @param host - */ -export function start (ctx: MeasureContext, pipelineFactory: (workspace: string) => Promise, port: number, host?: string): () => void { +export function start ( + ctx: MeasureContext, + pipelineFactory: (workspace: string) => Promise, + port: number, + host?: string +): () => void { console.log(`starting server on port ${port} ...`) const sessions = new SessionManager() @@ -166,11 +254,14 @@ export function start (ctx: MeasureContext, pipelineFactory: (workspace: string) wss.on('connection', async (ws: WebSocket, request: any, token: Token) => { const buffer: string[] = [] - ws.on('message', (msg: string) => { buffer.push(msg) }) - const session = await sessions.addSession(ws, token, pipelineFactory) + ws.on('message', (msg: string) => { + buffer.push(msg) + }) + const session = await sessions.addSession(ctx, ws, token, pipelineFactory) // eslint-disable-next-line @typescript-eslint/no-misused-promises ws.on('message', async (msg: string) => await handleRequest(ctx, session, ws, msg)) - ws.on('close', (code: number, reason: string) => sessions.close(ws, token.workspace, code, reason)) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + ws.on('close', async (code: number, reason: string) => await sessions.close(ctx, ws, token.workspace, code, reason)) for (const msg of buffer) { await handleRequest(ctx, session, ws, msg) @@ -183,7 +274,7 @@ export function start (ctx: MeasureContext, pipelineFactory: (workspace: string) try { const payload = decodeToken(token ?? '') console.log('client connected with payload', payload) - wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws, request, payload)) + wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws, request, payload)) } catch (err) { console.log('unauthorized client') socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')