UBERF-7532: Bulk operations for triggers (#6023)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2024-07-09 00:04:05 +07:00 committed by GitHub
parent fa82ee1939
commit 4eac1927f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 656 additions and 693 deletions

View File

@ -13,11 +13,11 @@
// limitations under the License. // limitations under the License.
// //
import { addLocation } from '@hcengineering/platform' import { devModelId } from '@hcengineering/devmodel'
import { PresentationClientHook } from '@hcengineering/devmodel-resources'
import login from '@hcengineering/login' import login from '@hcengineering/login'
import { setMetadata } from '@hcengineering/platform' import { addLocation, setMetadata } from '@hcengineering/platform'
import devmodel, { devModelId } from '@hcengineering/devmodel' import presentation from '@hcengineering/presentation'
import client from '@hcengineering/client'
export function configurePlatformDevServer() { export function configurePlatformDevServer() {
console.log('Use Endpoint override:', process.env.LOGIN_ENDPOINT) console.log('Use Endpoint override:', process.env.LOGIN_ENDPOINT)
@ -28,6 +28,6 @@ export function configurePlatformDevServer() {
} }
function enableDevModel() { function enableDevModel() {
setMetadata(client.metadata.ClientHook, devmodel.hook.Hook) setMetadata(presentation.metadata.ClientHook, new PresentationClientHook())
addLocation(devModelId, () => import(/* webpackChunkName: "devmodel" */ '@hcengineering/devmodel-resources')) addLocation(devModelId, () => import(/* webpackChunkName: "devmodel" */ '@hcengineering/devmodel-resources'))
} }

View File

@ -122,9 +122,6 @@ describe('client', () => {
clean: async (domain: Domain, docs: Ref<Doc>[]) => {}, clean: async (domain: Domain, docs: Ref<Doc>[]) => {},
loadModel: async (last: Timestamp) => clone(txes), loadModel: async (last: Timestamp) => clone(txes),
getAccount: async () => null as unknown as Account, getAccount: async () => null as unknown as Account,
measure: async () => {
return async () => ({ time: 0, serverTime: 0 })
},
sendForceClose: async () => {} sendForceClose: async () => {}
} }
} }

View File

@ -73,7 +73,6 @@ export async function connect (handler: (tx: Tx) => void): Promise<ClientConnect
clean: async (domain: Domain, docs: Ref<Doc>[]) => {}, clean: async (domain: Domain, docs: Ref<Doc>[]) => {},
loadModel: async (last: Timestamp) => txes, loadModel: async (last: Timestamp) => txes,
getAccount: async () => null as unknown as Account, getAccount: async () => null as unknown as Account,
measure: async () => async () => ({ time: 0, serverTime: 0 }),
sendForceClose: async () => {} sendForceClose: async () => {}
} }
} }

View File

@ -47,17 +47,10 @@ export interface Client extends Storage, FulltextStorage {
close: () => Promise<void> close: () => Promise<void>
} }
export type MeasureDoneOperation = () => Promise<{ time: number, serverTime: number }>
export interface MeasureClient extends Client {
// Will perform on server operation measure and will return a local client time and on server time
measure: (operationName: string) => Promise<MeasureDoneOperation>
}
/** /**
* @public * @public
*/ */
export interface AccountClient extends MeasureClient { export interface AccountClient extends Client {
getAccount: () => Promise<Account> getAccount: () => Promise<Account>
} }
@ -97,11 +90,9 @@ export interface ClientConnection extends Storage, FulltextStorage, BackupClient
// If hash is passed, will return LoadModelResponse // If hash is passed, will return LoadModelResponse
loadModel: (last: Timestamp, hash?: string) => Promise<Tx[] | LoadModelResponse> loadModel: (last: Timestamp, hash?: string) => Promise<Tx[] | LoadModelResponse>
getAccount: () => Promise<Account> getAccount: () => Promise<Account>
measure: (operationName: string) => Promise<MeasureDoneOperation>
} }
class ClientImpl implements AccountClient, BackupClient, MeasureClient { class ClientImpl implements AccountClient, BackupClient {
notify?: (...tx: Tx[]) => void notify?: (...tx: Tx[]) => void
hierarchy!: Hierarchy hierarchy!: Hierarchy
model!: ModelDb model!: ModelDb
@ -163,10 +154,6 @@ class ClientImpl implements AccountClient, BackupClient, MeasureClient {
return result return result
} }
async measure (operationName: string): Promise<MeasureDoneOperation> {
return await this.conn.measure(operationName)
}
async updateFromRemote (...tx: Tx[]): Promise<void> { async updateFromRemote (...tx: Tx[]): Promise<void> {
for (const t of tx) { for (const t of tx) {
try { try {

View File

@ -312,8 +312,8 @@ export class TxOperations implements Omit<Client, 'notify'> {
return this.removeDoc(doc._class, doc.space, doc._id) return this.removeDoc(doc._class, doc.space, doc._id)
} }
apply (scope: string): ApplyOperations { apply (scope: string, measure?: string): ApplyOperations {
return new ApplyOperations(this, scope) return new ApplyOperations(this, scope, measure)
} }
async diffUpdate<T extends Doc = Doc>( async diffUpdate<T extends Doc = Doc>(
@ -423,6 +423,12 @@ export class TxOperations implements Omit<Client, 'notify'> {
} }
} }
export interface CommitResult {
result: boolean
time: number
serverTime: number
}
/** /**
* @public * @public
* *
@ -436,7 +442,8 @@ export class ApplyOperations extends TxOperations {
notMatches: DocumentClassQuery<Doc>[] = [] notMatches: DocumentClassQuery<Doc>[] = []
constructor ( constructor (
readonly ops: TxOperations, readonly ops: TxOperations,
readonly scope: string readonly scope: string,
readonly measureName?: string
) { ) {
const txClient: Client = { const txClient: Client = {
getHierarchy: () => ops.client.getHierarchy(), getHierarchy: () => ops.client.getHierarchy(),
@ -465,23 +472,28 @@ export class ApplyOperations extends TxOperations {
return this return this
} }
async commit (notify: boolean = true, extraNotify: Ref<Class<Doc>>[] = []): Promise<boolean> { async commit (notify: boolean = true, extraNotify: Ref<Class<Doc>>[] = []): Promise<CommitResult> {
if (this.txes.length > 0) { if (this.txes.length > 0) {
return ( const st = Date.now()
await ((await this.ops.tx( const result = await ((await this.ops.tx(
this.ops.txFactory.createTxApplyIf( this.ops.txFactory.createTxApplyIf(
core.space.Tx, core.space.Tx,
this.scope, this.scope,
this.matches, this.matches,
this.notMatches, this.notMatches,
this.txes, this.txes,
notify, this.measureName,
extraNotify notify,
) extraNotify
)) as Promise<TxApplyResult>) )
).success )) as Promise<TxApplyResult>)
return {
result: result.success,
time: Date.now() - st,
serverTime: result.serverTime
}
} }
return true return { result: true, time: 0, serverTime: 0 }
} }
} }

View File

@ -33,14 +33,15 @@ export interface StorageIterator {
close: (ctx: MeasureContext) => Promise<void> close: (ctx: MeasureContext) => Promise<void>
} }
export type BroadcastTargets = Record<string, (tx: Tx) => string[] | undefined>
export interface SessionOperationContext { export interface SessionOperationContext {
ctx: MeasureContext ctx: MeasureContext
// A parts of derived data to deal with after operation will be complete // A parts of derived data to deal with after operation will be complete
derived: { derived: {
derived: Tx[] txes: Tx[]
target?: string[] targets: BroadcastTargets // A set of broadcast filters if required
}[] }
with: <T>( with: <T>(
name: string, name: string,
params: ParamsType, params: ParamsType,

View File

@ -28,13 +28,13 @@ import type {
Space, Space,
Timestamp Timestamp
} from './classes' } from './classes'
import { clone } from './clone'
import core from './component' import core from './component'
import { setObjectValue } from './objvalue' import { setObjectValue } from './objvalue'
import { _getOperator } from './operator' import { _getOperator } from './operator'
import { _toDoc } from './proxy' import { _toDoc } from './proxy'
import type { DocumentQuery, TxResult } from './storage' import type { DocumentQuery, TxResult } from './storage'
import { generateId } from './utils' import { generateId } from './utils'
import { clone } from './clone'
/** /**
* @public * @public
@ -137,10 +137,14 @@ export interface TxApplyIf extends Tx {
// If passed, will send WorkspaceEvent.BulkUpdate event with list of classes to update // If passed, will send WorkspaceEvent.BulkUpdate event with list of classes to update
extraNotify?: Ref<Class<Doc>>[] extraNotify?: Ref<Class<Doc>>[]
// If defined will go into a separate measure section
measureName?: string
} }
export interface TxApplyResult { export interface TxApplyResult {
success: boolean success: boolean
serverTime: number
} }
/** /**
@ -618,6 +622,7 @@ export class TxFactory {
match: DocumentClassQuery<Doc>[], match: DocumentClassQuery<Doc>[],
notMatch: DocumentClassQuery<Doc>[], notMatch: DocumentClassQuery<Doc>[],
txes: TxCUD<Doc>[], txes: TxCUD<Doc>[],
measureName: string | undefined,
notify: boolean = true, notify: boolean = true,
extraNotify: Ref<Class<Doc>>[] = [], extraNotify: Ref<Class<Doc>>[] = [],
modifiedOn?: Timestamp, modifiedOn?: Timestamp,
@ -634,6 +639,7 @@ export class TxFactory {
match, match,
notMatch, notMatch,
txes, txes,
measureName,
notify, notify,
extraNotify extraNotify
} }

View File

@ -8,8 +8,6 @@ import {
type FindOptions, type FindOptions,
type FindResult, type FindResult,
type Hierarchy, type Hierarchy,
type MeasureClient,
type MeasureDoneOperation,
type ModelDb, type ModelDb,
type Ref, type Ref,
type SearchOptions, type SearchOptions,
@ -65,7 +63,7 @@ export type PresentationMiddlewareCreator = (client: Client, next?: Presentation
/** /**
* @public * @public
*/ */
export interface PresentationPipeline extends MeasureClient, Exclude<PresentationMiddleware, 'next'> { export interface PresentationPipeline extends Client, Exclude<PresentationMiddleware, 'next'> {
close: () => Promise<void> close: () => Promise<void>
} }
@ -75,7 +73,7 @@ export interface PresentationPipeline extends MeasureClient, Exclude<Presentatio
export class PresentationPipelineImpl implements PresentationPipeline { export class PresentationPipelineImpl implements PresentationPipeline {
private head: PresentationMiddleware | undefined private head: PresentationMiddleware | undefined
private constructor (readonly client: MeasureClient) {} private constructor (readonly client: Client) {}
getHierarchy (): Hierarchy { getHierarchy (): Hierarchy {
return this.client.getHierarchy() return this.client.getHierarchy()
@ -89,11 +87,7 @@ export class PresentationPipelineImpl implements PresentationPipeline {
await this.head?.notifyTx(...tx) await this.head?.notifyTx(...tx)
} }
async measure (operationName: string): Promise<MeasureDoneOperation> { static create (client: Client, constructors: PresentationMiddlewareCreator[]): PresentationPipeline {
return await this.client.measure(operationName)
}
static create (client: MeasureClient, constructors: PresentationMiddlewareCreator[]): PresentationPipeline {
const pipeline = new PresentationPipelineImpl(client) const pipeline = new PresentationPipelineImpl(client)
pipeline.head = pipeline.buildChain(constructors) pipeline.head = pipeline.buildChain(constructors)
return pipeline return pipeline

View File

@ -14,26 +14,64 @@
// limitations under the License. // limitations under the License.
// //
import { type Mixin, type Class, type Ref } from '@hcengineering/core' import {
type Class,
type Client,
type Doc,
type DocumentQuery,
type FindOptions,
type FindResult,
type Mixin,
type Ref,
type SearchOptions,
type SearchQuery,
type SearchResult,
type Tx,
type TxResult,
type WithLookup
} from '@hcengineering/core'
import type { Asset, IntlString, Metadata, Plugin, StatusCode } from '@hcengineering/platform' import type { Asset, IntlString, Metadata, Plugin, StatusCode } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform'
import { type ComponentExtensionId } from '@hcengineering/ui' import { type ComponentExtensionId } from '@hcengineering/ui'
import { type PresentationMiddlewareFactory } from './pipeline' import { type PresentationMiddlewareFactory } from './pipeline'
import type { PreviewConfig } from './preview'
import { import {
type ComponentPointExtension, type ComponentPointExtension,
type DocRules,
type DocCreateExtension, type DocCreateExtension,
type DocRules,
type FilePreviewExtension, type FilePreviewExtension,
type ObjectSearchCategory, type InstantTransactions,
type InstantTransactions type ObjectSearchCategory
} from './types' } from './types'
import type { PreviewConfig } from './preview'
/** /**
* @public * @public
*/ */
export const presentationId = 'presentation' as Plugin export const presentationId = 'presentation' as Plugin
/**
* @public
*/
export interface ClientHook {
findAll: <T extends Doc>(
client: Client,
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
) => Promise<FindResult<T>>
findOne: <T extends Doc>(
client: Client,
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
) => Promise<WithLookup<T> | undefined>
tx: (client: Client, tx: Tx) => Promise<TxResult>
searchFulltext: (client: Client, query: SearchQuery, options: SearchOptions) => Promise<SearchResult>
}
export default plugin(presentationId, { export default plugin(presentationId, {
class: { class: {
ObjectSearchCategory: '' as Ref<Class<ObjectSearchCategory>>, ObjectSearchCategory: '' as Ref<Class<ObjectSearchCategory>>,
@ -95,7 +133,8 @@ export default plugin(presentationId, {
CollaboratorApiUrl: '' as Metadata<string>, CollaboratorApiUrl: '' as Metadata<string>,
Token: '' as Metadata<string>, Token: '' as Metadata<string>,
FrontUrl: '' as Asset, FrontUrl: '' as Asset,
PreviewConfig: '' as Metadata<PreviewConfig | undefined> PreviewConfig: '' as Metadata<PreviewConfig | undefined>,
ClientHook: '' as Metadata<ClientHook>
}, },
status: { status: {
FileTooLarge: '' as StatusCode FileTooLarge: '' as StatusCode

View File

@ -33,8 +33,6 @@ import core, {
type FindOptions, type FindOptions,
type FindResult, type FindResult,
type Hierarchy, type Hierarchy,
type MeasureClient,
type MeasureDoneOperation,
type Mixin, type Mixin,
type Obj, type Obj,
type Blob as PlatformBlob, type Blob as PlatformBlob,
@ -65,7 +63,7 @@ export { reduceCalls } from '@hcengineering/core'
let liveQuery: LQ let liveQuery: LQ
let rawLiveQuery: LQ let rawLiveQuery: LQ
let client: TxOperations & MeasureClient & OptimisticTxes let client: TxOperations & Client & OptimisticTxes
let pipeline: PresentationPipeline let pipeline: PresentationPipeline
const txListeners: Array<(...tx: Tx[]) => void> = [] const txListeners: Array<(...tx: Tx[]) => void> = []
@ -95,16 +93,15 @@ export interface OptimisticTxes {
pendingCreatedDocs: Writable<Record<Ref<Doc>, boolean>> pendingCreatedDocs: Writable<Record<Ref<Doc>, boolean>>
} }
class UIClient extends TxOperations implements Client, MeasureClient, OptimisticTxes { class UIClient extends TxOperations implements Client, OptimisticTxes {
hook = getMetadata(plugin.metadata.ClientHook)
constructor ( constructor (
client: MeasureClient, client: Client,
private readonly liveQuery: Client private readonly liveQuery: Client
) { ) {
super(client, getCurrentAccount()._id) super(client, getCurrentAccount()._id)
} }
afterMeasure: Tx[] = []
measureOp?: MeasureDoneOperation
protected pendingTxes = new Set<Ref<Tx>>() protected pendingTxes = new Set<Ref<Tx>>()
protected _pendingCreatedDocs = writable<Record<Ref<Doc>, boolean>>({}) protected _pendingCreatedDocs = writable<Record<Ref<Doc>, boolean>>({})
@ -113,34 +110,30 @@ class UIClient extends TxOperations implements Client, MeasureClient, Optimistic
} }
async doNotify (...tx: Tx[]): Promise<void> { async doNotify (...tx: Tx[]): Promise<void> {
if (this.measureOp !== undefined) { const pending = get(this._pendingCreatedDocs)
this.afterMeasure.push(...tx) let pendingUpdated = false
} else { tx.forEach((t) => {
const pending = get(this._pendingCreatedDocs) if (this.pendingTxes.has(t._id)) {
let pendingUpdated = false this.pendingTxes.delete(t._id)
tx.forEach((t) => {
if (this.pendingTxes.has(t._id)) {
this.pendingTxes.delete(t._id)
// Only CUD tx can be pending now // Only CUD tx can be pending now
const innerTx = TxProcessor.extractTx(t) as TxCUD<Doc> const innerTx = TxProcessor.extractTx(t) as TxCUD<Doc>
if (innerTx._class === core.class.TxCreateDoc) { if (innerTx._class === core.class.TxCreateDoc) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete pending[innerTx.objectId] delete pending[innerTx.objectId]
pendingUpdated = true pendingUpdated = true
}
} }
})
if (pendingUpdated) {
this._pendingCreatedDocs.set(pending)
} }
})
// We still want to notify about all transactions because there might be queries created after if (pendingUpdated) {
// the early applied transaction this._pendingCreatedDocs.set(pending)
// For old queries there's a check anyway that prevents the same document from being added twice
await this.provideNotify(...tx)
} }
// We still want to notify about all transactions because there might be queries created after
// the early applied transaction
// For old queries there's a check anyway that prevents the same document from being added twice
await this.provideNotify(...tx)
} }
private async provideNotify (...tx: Tx[]): Promise<void> { private async provideNotify (...tx: Tx[]): Promise<void> {
@ -165,6 +158,9 @@ class UIClient extends TxOperations implements Client, MeasureClient, Optimistic
query: DocumentQuery<T>, query: DocumentQuery<T>,
options?: FindOptions<T> options?: FindOptions<T>
): Promise<FindResult<T>> { ): Promise<FindResult<T>> {
if (this.hook !== undefined) {
return await this.hook.findAll(this.liveQuery, _class, query, options)
}
return await this.liveQuery.findAll(_class, query, options) return await this.liveQuery.findAll(_class, query, options)
} }
@ -173,12 +169,17 @@ class UIClient extends TxOperations implements Client, MeasureClient, Optimistic
query: DocumentQuery<T>, query: DocumentQuery<T>,
options?: FindOptions<T> options?: FindOptions<T>
): Promise<WithLookup<T> | undefined> { ): Promise<WithLookup<T> | undefined> {
if (this.hook !== undefined) {
return await this.hook.findOne(this.liveQuery, _class, query, options)
}
return await this.liveQuery.findOne(_class, query, options) return await this.liveQuery.findOne(_class, query, options)
} }
override async tx (tx: Tx): Promise<TxResult> { override async tx (tx: Tx): Promise<TxResult> {
void this.notifyEarly(tx) void this.notifyEarly(tx)
if (this.hook !== undefined) {
return await this.hook.tx(this.client, tx)
}
return await this.client.tx(tx) return await this.client.tx(tx)
} }
@ -221,39 +222,24 @@ class UIClient extends TxOperations implements Client, MeasureClient, Optimistic
} }
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> { async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
return await this.client.searchFulltext(query, options) if (this.hook !== undefined) {
} return await this.hook.searchFulltext(this.client, query, options)
async measure (operationName: string): Promise<MeasureDoneOperation> {
// return await (this.client as MeasureClient).measure(operationName)
const mop = await (this.client as MeasureClient).measure(operationName)
this.measureOp = mop
return async () => {
const result = await mop()
this.measureOp = undefined
if (this.afterMeasure.length > 0) {
const txes = this.afterMeasure
this.afterMeasure = []
for (const tx of txes) {
await this.doNotify(tx)
}
}
return result
} }
return await this.client.searchFulltext(query, options)
} }
} }
/** /**
* @public * @public
*/ */
export function getClient (): TxOperations & MeasureClient & OptimisticTxes { export function getClient (): TxOperations & Client & OptimisticTxes {
return client return client
} }
/** /**
* @public * @public
*/ */
export async function setClient (_client: MeasureClient): Promise<void> { export async function setClient (_client: Client): Promise<void> {
if (liveQuery !== undefined) { if (liveQuery !== undefined) {
await liveQuery.close() await liveQuery.close()
} }
@ -276,6 +262,7 @@ export async function setClient (_client: MeasureClient): Promise<void> {
liveQuery = new LQ(pipeline) liveQuery = new LQ(pipeline)
const uiClient = new UIClient(pipeline, liveQuery) const uiClient = new UIClient(pipeline, liveQuery)
client = uiClient client = uiClient
_client.notify = (...tx: Tx[]) => { _client.notify = (...tx: Tx[]) => {
@ -285,7 +272,6 @@ export async function setClient (_client: MeasureClient): Promise<void> {
await refreshClient(true) await refreshClient(true)
} }
} }
/** /**
* @public * @public
*/ */

View File

@ -98,7 +98,6 @@ FulltextStorage & {
searchFulltext: async (query: SearchQuery, options: SearchOptions): Promise<SearchResult> => { searchFulltext: async (query: SearchQuery, options: SearchOptions): Promise<SearchResult> => {
return { docs: [] } return { docs: [] }
}, },
measure: async () => async () => ({ time: 0, serverTime: 0 }),
sendForceClose: async () => {} sendForceClose: async () => {}
} }
} }

View File

@ -14,12 +14,12 @@
--> -->
<script lang="ts"> <script lang="ts">
import { DirectMessage } from '@hcengineering/chunter' import { DirectMessage } from '@hcengineering/chunter'
import { Avatar, CombineAvatars, personAccountByIdStore } from '@hcengineering/contact-resources'
import { Icon, IconSize } from '@hcengineering/ui'
import contact, { Person, PersonAccount } from '@hcengineering/contact' import contact, { Person, PersonAccount } from '@hcengineering/contact'
import { classIcon } from '@hcengineering/view-resources' import { Avatar, CombineAvatars, personAccountByIdStore, personByIdStore } from '@hcengineering/contact-resources'
import { getClient } from '@hcengineering/presentation'
import { Account, IdMap } from '@hcengineering/core' import { Account, IdMap } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Icon, IconSize } from '@hcengineering/ui'
import { classIcon } from '@hcengineering/view-resources'
import chunter from '../plugin' import chunter from '../plugin'
import { getDmPersons } from '../utils' import { getDmPersons } from '../utils'
@ -33,10 +33,11 @@
let persons: Person[] = [] let persons: Person[] = []
$: value && $: if (value !== undefined) {
getDmPersons(client, value).then((res) => { void getDmPersons(client, value, $personByIdStore).then((res) => {
persons = res persons = res
}) })
}
let avatarSize = size let avatarSize = size

View File

@ -13,15 +13,15 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import activity, { ActivityMessage } from '@hcengineering/activity'
import { Analytics } from '@hcengineering/analytics' import { Analytics } from '@hcengineering/analytics'
import { createEventDispatcher } from 'svelte'
import { AttachmentRefInput } from '@hcengineering/attachment-resources' import { AttachmentRefInput } from '@hcengineering/attachment-resources'
import { Class, Doc, generateId, getCurrentAccount, Ref } from '@hcengineering/core'
import { createQuery, DraftController, draftsStore, getClient, isSpace } from '@hcengineering/presentation'
import chunter, { ChatMessage, ThreadMessage } from '@hcengineering/chunter' import chunter, { ChatMessage, ThreadMessage } from '@hcengineering/chunter'
import { PersonAccount } from '@hcengineering/contact' import { PersonAccount } from '@hcengineering/contact'
import activity, { ActivityMessage } from '@hcengineering/activity' import { Class, Doc, generateId, getCurrentAccount, Ref, type CommitResult } from '@hcengineering/core'
import { createQuery, DraftController, draftsStore, getClient, isSpace } from '@hcengineering/presentation'
import { EmptyMarkup } from '@hcengineering/text-editor' import { EmptyMarkup } from '@hcengineering/text-editor'
import { createEventDispatcher } from 'svelte'
export let object: Doc export let object: Doc
export let chatMessage: ChatMessage | undefined = undefined export let chatMessage: ChatMessage | undefined = undefined
@ -100,32 +100,20 @@
} }
async function handleCreate (event: CustomEvent, _id: Ref<ChatMessage>): Promise<void> { async function handleCreate (event: CustomEvent, _id: Ref<ChatMessage>): Promise<void> {
const doneOp = getClient().measure(`chunter.create.${_class} ${object._class}`)
try { try {
await createMessage(event, _id) const res = await createMessage(event, _id, `chunter.create.${_class} ${object._class}`)
const d1 = Date.now() console.log(`create.${_class} measure`, res.serverTime, res.time)
void (await doneOp)().then((res) => {
console.log(`create.${_class} measure`, res, Date.now() - d1)
})
} catch (err: any) { } catch (err: any) {
void (await doneOp)()
Analytics.handleError(err) Analytics.handleError(err)
console.error(err) console.error(err)
} }
} }
async function handleEdit (event: CustomEvent): Promise<void> { async function handleEdit (event: CustomEvent): Promise<void> {
const doneOp = getClient().measure(`chunter.edit.${_class} ${object._class}`)
try { try {
await editMessage(event) await editMessage(event)
const d1 = Date.now()
void (await doneOp)().then((res) => {
console.log(`edit.${_class} measure`, res, Date.now() - d1)
})
} catch (err: any) { } catch (err: any) {
void (await doneOp)()
Analytics.handleError(err) Analytics.handleError(err)
console.error(err) console.error(err)
} }
@ -148,9 +136,9 @@
loading = false loading = false
} }
async function createMessage (event: CustomEvent, _id: Ref<ChatMessage>): Promise<void> { async function createMessage (event: CustomEvent, _id: Ref<ChatMessage>, msg: string): Promise<CommitResult> {
const { message, attachments } = event.detail const { message, attachments } = event.detail
const operations = client.apply(_id) const operations = client.apply(_id, msg)
if (_class === chunter.class.ThreadMessage) { if (_class === chunter.class.ThreadMessage) {
const parentMessage = object as ActivityMessage const parentMessage = object as ActivityMessage
@ -188,7 +176,7 @@
_id _id
) )
} }
await operations.commit() return await operations.commit()
} }
async function editMessage (event: CustomEvent): Promise<void> { async function editMessage (event: CustomEvent): Promise<void> {

View File

@ -13,17 +13,17 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { Class, Doc, getCurrentAccount, groupByArray, Ref, SortingOrder } from '@hcengineering/core'
import notification, { DocNotifyContext } from '@hcengineering/notification'
import { createQuery, getClient, LiveQuery } from '@hcengineering/presentation'
import activity from '@hcengineering/activity' import activity from '@hcengineering/activity'
import { IntlString } from '@hcengineering/platform' import { Class, Doc, getCurrentAccount, groupByArray, reduceCalls, Ref, SortingOrder } from '@hcengineering/core'
import { Action } from '@hcengineering/ui' import notification, { DocNotifyContext } from '@hcengineering/notification'
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources' import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { IntlString } from '@hcengineering/platform'
import { createQuery, getClient, LiveQuery } from '@hcengineering/presentation'
import { Action } from '@hcengineering/ui'
import chunter from '../../../plugin'
import { ChatGroup, ChatNavGroupModel } from '../types' import { ChatGroup, ChatNavGroupModel } from '../types'
import ChatNavSection from './ChatNavSection.svelte' import ChatNavSection from './ChatNavSection.svelte'
import chunter from '../../../plugin'
export let object: Doc | undefined export let object: Doc | undefined
export let model: ChatNavGroupModel export let model: ChatNavGroupModel
@ -68,7 +68,11 @@
$: loadObjects(contexts) $: loadObjects(contexts)
$: void getSections(objectsByClass, model, shouldPushObject ? object : undefined).then((res) => { $: pushObj = shouldPushObject ? object : undefined
const getPushObj = () => pushObj as Doc
$: void getSections(objectsByClass, model, pushObj, getPushObj, (res) => {
sections = res sections = res
}) })
@ -112,63 +116,68 @@
return 'activity' return 'activity'
} }
async function getSections ( const getSections = reduceCalls(
objectsByClass: Map<Ref<Class<Doc>>, Doc[]>, async (
model: ChatNavGroupModel, objectsByClass: Map<Ref<Class<Doc>>, Doc[]>,
object: Doc | undefined model: ChatNavGroupModel,
): Promise<Section[]> { object: { _id: Doc['_id'], _class: Doc['_class'] } | undefined,
const result: Section[] = [] getPushObj: () => Doc,
handler: (result: Section[]) => void
): Promise<void> => {
const result: Section[] = []
if (!model.wrap) { if (!model.wrap) {
result.push({ result.push({
id: model.id, id: model.id,
objects: Array.from(objectsByClass.values()).flat(), objects: Array.from(objectsByClass.values()).flat(),
label: model.label ?? chunter.string.Channels label: model.label ?? chunter.string.Channels
}) })
return result handler(result)
} return
let isObjectPushed = false
if (
Array.from(objectsByClass.values())
.flat()
.some((o) => o._id === object?._id)
) {
isObjectPushed = true
}
for (const [_class, objects] of objectsByClass.entries()) {
const clazz = hierarchy.getClass(_class)
const sectionObjects = [...objects]
if (object && _class === object._class && !objects.some(({ _id }) => _id === object._id)) {
isObjectPushed = true
sectionObjects.push(object)
} }
result.push({ let isObjectPushed = false
id: _class,
_class, if (
objects: sectionObjects, Array.from(objectsByClass.values())
label: clazz.pluralLabel ?? clazz.label .flat()
}) .some((o) => o._id === object?._id)
) {
isObjectPushed = true
}
for (const [_class, objects] of objectsByClass.entries()) {
const clazz = hierarchy.getClass(_class)
const sectionObjects = [...objects]
if (object !== undefined && _class === object._class && !objects.some(({ _id }) => _id === object._id)) {
isObjectPushed = true
sectionObjects.push(getPushObj())
}
result.push({
id: _class,
_class,
objects: sectionObjects,
label: clazz.pluralLabel ?? clazz.label
})
}
if (!isObjectPushed && object !== undefined) {
const clazz = hierarchy.getClass(object._class)
result.push({
id: object._id,
_class: object._class,
objects: [getPushObj()],
label: clazz.pluralLabel ?? clazz.label
})
}
handler(result.sort((s1, s2) => s1.label.localeCompare(s2.label)))
} }
)
if (!isObjectPushed && object) {
const clazz = hierarchy.getClass(object._class)
result.push({
id: object._id,
_class: object._class,
objects: [object],
label: clazz.pluralLabel ?? clazz.label
})
}
return result.sort((s1, s2) => s1.label.localeCompare(s2.label))
}
function getSectionActions (section: Section, contexts: DocNotifyContext[]): Action[] { function getSectionActions (section: Section, contexts: DocNotifyContext[]): Action[] {
if (model.getActionsFn === undefined) { if (model.getActionsFn === undefined) {

View File

@ -13,20 +13,20 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { Doc, Ref } from '@hcengineering/core' import contact from '@hcengineering/contact'
import { personAccountByIdStore, statusByUserStore } from '@hcengineering/contact-resources'
import { Doc, reduceCalls, Ref } from '@hcengineering/core'
import { DocNotifyContext } from '@hcengineering/notification' import { DocNotifyContext } from '@hcengineering/notification'
import { getResource, IntlString, translate } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation' import { getClient } from '@hcengineering/presentation'
import ui, { Action, AnySvelteComponent, IconSize, ModernButton, NavGroup } from '@hcengineering/ui' import ui, { Action, AnySvelteComponent, IconSize, ModernButton, NavGroup } from '@hcengineering/ui'
import { getDocTitle } from '@hcengineering/view-resources'
import contact from '@hcengineering/contact'
import { getResource, IntlString, translate } from '@hcengineering/platform'
import view from '@hcengineering/view' import view from '@hcengineering/view'
import { personAccountByIdStore, statusByUserStore } from '@hcengineering/contact-resources' import { getDocTitle } from '@hcengineering/view-resources'
import ChatNavItem from './ChatNavItem.svelte'
import chunter from '../../../plugin' import chunter from '../../../plugin'
import { getChannelName, getObjectIcon } from '../../../utils'
import { ChatNavItemModel, SortFnOptions } from '../types' import { ChatNavItemModel, SortFnOptions } from '../types'
import { getObjectIcon, getChannelName } from '../../../utils' import ChatNavItem from './ChatNavItem.svelte'
export let id: string export let id: string
export let header: IntlString export let header: IntlString
@ -47,7 +47,7 @@
let canShowMore = false let canShowMore = false
let isShownMore = false let isShownMore = false
$: void getChatNavItems(objects).then((res) => { $: void getChatNavItems(objects, (res) => {
items = res items = res
}) })
@ -60,44 +60,46 @@
$: visibleItems = getVisibleItems(canShowMore, isShownMore, maxItems, sortedItems, objectId) $: visibleItems = getVisibleItems(canShowMore, isShownMore, maxItems, sortedItems, objectId)
async function getChatNavItems (objects: Doc[]): Promise<ChatNavItemModel[]> { const getChatNavItems = reduceCalls(
const items: ChatNavItemModel[] = [] async (objects: Doc[], handler: (items: ChatNavItemModel[]) => void): Promise<void> => {
const items: ChatNavItemModel[] = []
for (const object of objects) { for (const object of objects) {
const { _class } = object const { _class } = object
const iconMixin = hierarchy.classHierarchyMixin(_class, view.mixin.ObjectIcon) const iconMixin = hierarchy.classHierarchyMixin(_class, view.mixin.ObjectIcon)
const titleIntl = client.getHierarchy().getClass(_class).label const titleIntl = client.getHierarchy().getClass(_class).label
const isPerson = hierarchy.isDerived(_class, contact.class.Person) const isPerson = hierarchy.isDerived(_class, contact.class.Person)
const isDocChat = !hierarchy.isDerived(_class, chunter.class.ChunterSpace) const isDocChat = !hierarchy.isDerived(_class, chunter.class.ChunterSpace)
const isDirect = hierarchy.isDerived(_class, chunter.class.DirectMessage) const isDirect = hierarchy.isDerived(_class, chunter.class.DirectMessage)
const iconSize: IconSize = isDirect || isPerson ? 'x-small' : 'small' const iconSize: IconSize = isDirect || isPerson ? 'x-small' : 'small'
let icon: AnySvelteComponent | undefined = undefined let icon: AnySvelteComponent | undefined = undefined
if (iconMixin?.component) { if (iconMixin?.component) {
icon = await getResource(iconMixin.component) icon = await getResource(iconMixin.component)
}
const hasId = hierarchy.classHierarchyMixin(object._class, view.mixin.ObjectIdentifier) !== undefined
const showDescription = hasId && isDocChat && !isPerson
items.push({
id: object._id,
object,
title: (await getChannelName(object._id, object._class, object)) ?? (await translate(titleIntl, {})),
description: showDescription ? await getDocTitle(client, object._id, object._class, object) : undefined,
icon: icon ?? getObjectIcon(_class),
iconProps: { showStatus: true },
iconSize,
withIconBackground: !isDirect && !isPerson,
isSecondary: isDocChat && !isPerson
})
} }
const hasId = hierarchy.classHierarchyMixin(object._class, view.mixin.ObjectIdentifier) !== undefined handler(items)
const showDescription = hasId && isDocChat && !isPerson
items.push({
id: object._id,
object,
title: (await getChannelName(object._id, object._class, object)) ?? (await translate(titleIntl, {})),
description: showDescription ? await getDocTitle(client, object._id, object._class, object) : undefined,
icon: icon ?? getObjectIcon(_class),
iconProps: { showStatus: true },
iconSize,
withIconBackground: !isDirect && !isPerson,
isSecondary: isDocChat && !isPerson
})
} }
)
return items
}
function onShowMore (): void { function onShowMore (): void {
isShownMore = !isShownMore isShownMore = !isShownMore

View File

@ -110,7 +110,7 @@
/> />
</div> </div>
<Scroller shrink> <Scroller shrink>
{#each chatNavGroupModels as model} {#each chatNavGroupModels as model (model.id)}
<ChatNavGroup {object} {model} on:select /> <ChatNavGroup {object} {model} on:select />
{/each} {/each}
</Scroller> </Scroller>

View File

@ -304,8 +304,7 @@ function getPinnedActions (contexts: DocNotifyContext[]): Action[] {
} }
async function unpinAllChannels (contexts: DocNotifyContext[]): Promise<void> { async function unpinAllChannels (contexts: DocNotifyContext[]): Promise<void> {
const doneOp = await getClient().measure('unpinAllChannels') const ops = getClient().apply(generateId(), 'unpinAllChannels')
const ops = getClient().apply(generateId())
try { try {
for (const context of contexts) { for (const context of contexts) {
@ -313,7 +312,6 @@ async function unpinAllChannels (contexts: DocNotifyContext[]): Promise<void> {
} }
} finally { } finally {
await ops.commit() await ops.commit()
await doneOp()
} }
} }
@ -404,8 +402,7 @@ export function loadSavedAttachments (): void {
export async function removeActivityChannels (contexts: DocNotifyContext[]): Promise<void> { export async function removeActivityChannels (contexts: DocNotifyContext[]): Promise<void> {
const client = InboxNotificationsClientImpl.getClient() const client = InboxNotificationsClientImpl.getClient()
const notificationsByContext = get(client.inboxNotificationsByContext) const notificationsByContext = get(client.inboxNotificationsByContext)
const doneOp = await getClient().measure('removeActivityChannels') const ops = getClient().apply(generateId(), 'removeActivityChannels')
const ops = getClient().apply(generateId())
try { try {
for (const context of contexts) { for (const context of contexts) {
@ -418,15 +415,13 @@ export async function removeActivityChannels (contexts: DocNotifyContext[]): Pro
} }
} finally { } finally {
await ops.commit() await ops.commit()
await doneOp()
} }
} }
export async function readActivityChannels (contexts: DocNotifyContext[]): Promise<void> { export async function readActivityChannels (contexts: DocNotifyContext[]): Promise<void> {
const client = InboxNotificationsClientImpl.getClient() const client = InboxNotificationsClientImpl.getClient()
const notificationsByContext = get(client.inboxNotificationsByContext) const notificationsByContext = get(client.inboxNotificationsByContext)
const doneOp = await getClient().measure('readActivityChannels') const ops = getClient().apply(generateId(), 'readActivityChannels')
const ops = getClient().apply(generateId())
try { try {
for (const context of contexts) { for (const context of contexts) {
@ -441,6 +436,5 @@ export async function readActivityChannels (contexts: DocNotifyContext[]): Promi
} }
} finally { } finally {
await ops.commit() await ops.commit()
await doneOp()
} }
} }

View File

@ -32,7 +32,8 @@ import {
type IdMap, type IdMap,
type Ref, type Ref,
type Space, type Space,
type Timestamp type Timestamp,
type WithLookup
} from '@hcengineering/core' } from '@hcengineering/core'
import notification, { type DocNotifyContext, type InboxNotification } from '@hcengineering/notification' import notification, { type DocNotifyContext, type InboxNotification } from '@hcengineering/notification'
import { import {
@ -163,7 +164,11 @@ async function getDmAccounts (client: Client, space?: Space): Promise<PersonAcco
}) })
} }
export async function getDmPersons (client: Client, space: Space): Promise<Person[]> { export async function getDmPersons (
client: Client,
space: Space,
personsMap: Map<Ref<WithLookup<Person>>, WithLookup<Person>>
): Promise<Person[]> {
const personAccounts: PersonAccount[] = await getDmAccounts(client, space) const personAccounts: PersonAccount[] = await getDmAccounts(client, space)
const me = getCurrentAccount() as PersonAccount const me = getCurrentAccount() as PersonAccount
const persons: Person[] = [] const persons: Person[] = []
@ -172,7 +177,7 @@ export async function getDmPersons (client: Client, space: Space): Promise<Perso
let myPerson: Person | undefined let myPerson: Person | undefined
for (const personRef of personRefs) { for (const personRef of personRefs) {
const person = await client.findOne(contact.class.Person, { _id: personRef }) const person = personsMap.get(personRef) ?? (await client.findOne(contact.class.Person, { _id: personRef }))
if (person === undefined) { if (person === undefined) {
continue continue
} }
@ -192,8 +197,12 @@ export async function getDmPersons (client: Client, space: Space): Promise<Perso
return myPerson !== undefined ? [myPerson] : [] return myPerson !== undefined ? [myPerson] : []
} }
export async function DirectTitleProvider (client: Client, id: Ref<DirectMessage>): Promise<string> { export async function DirectTitleProvider (
const direct = await client.findOne(chunter.class.DirectMessage, { _id: id }) client: Client,
id: Ref<DirectMessage>,
doc?: DirectMessage
): Promise<string> {
const direct = doc ?? (await client.findOne(chunter.class.DirectMessage, { _id: id }))
if (direct === undefined) { if (direct === undefined) {
return '' return ''
@ -202,8 +211,8 @@ export async function DirectTitleProvider (client: Client, id: Ref<DirectMessage
return await getDmName(client, direct) return await getDmName(client, direct)
} }
export async function ChannelTitleProvider (client: Client, id: Ref<Channel>): Promise<string> { export async function ChannelTitleProvider (client: Client, id: Ref<Channel>, doc?: Channel): Promise<string> {
const channel = await client.findOne(chunter.class.Channel, { _id: id }) const channel = doc ?? (await client.findOne(chunter.class.Channel, { _id: id }))
if (channel === undefined) { if (channel === undefined) {
return '' return ''

View File

@ -28,7 +28,6 @@ import core, {
FindOptions, FindOptions,
FindResult, FindResult,
LoadModelResponse, LoadModelResponse,
MeasureDoneOperation,
Ref, Ref,
SearchOptions, SearchOptions,
SearchQuery, SearchQuery,
@ -524,26 +523,6 @@ class Connection implements ClientConnection {
return await promise.promise return await promise.promise
} }
async measure (operationName: string): Promise<MeasureDoneOperation> {
const dateNow = Date.now()
// Send measure-start
const mid = await this.sendRequest({
method: 'measure',
params: [operationName]
})
return async () => {
const serverTime: number = await this.sendRequest({
method: 'measure-done',
params: [operationName, mid]
})
return {
time: Date.now() - dateNow,
serverTime
}
}
}
async loadModel (last: Timestamp, hash?: string): Promise<Tx[] | LoadModelResponse> { async loadModel (last: Timestamp, hash?: string): Promise<Tx[] | LoadModelResponse> {
return await this.sendRequest({ method: 'loadModel', params: [last, hash] }) return await this.sendRequest({ method: 'loadModel', params: [last, hash] })
} }

View File

@ -28,14 +28,7 @@ import core, {
createClient, createClient,
type ClientConnection type ClientConnection
} from '@hcengineering/core' } from '@hcengineering/core'
import platform, { import platform, { Severity, Status, getMetadata, getPlugins, setPlatformStatus } from '@hcengineering/platform'
Severity,
Status,
getMetadata,
getPlugins,
getResource,
setPlatformStatus
} from '@hcengineering/platform'
import { connect } from './connection' import { connect } from './connection'
export { connect } export { connect }
@ -88,7 +81,7 @@ export default async () => {
): Promise<AccountClient> => { ): Promise<AccountClient> => {
const filterModel = getMetadata(clientPlugin.metadata.FilterModel) ?? false const filterModel = getMetadata(clientPlugin.metadata.FilterModel) ?? false
let client = createClient( const client = createClient(
(handler: TxHandler) => { (handler: TxHandler) => {
const url = concatLink(endpoint, `/${token}`) const url = concatLink(endpoint, `/${token}`)
@ -144,8 +137,6 @@ export default async () => {
createModelPersistence(getWSFromToken(token)), createModelPersistence(getWSFromToken(token)),
ctx ctx
) )
// Check if we had dev hook for client.
client = hookClient(client)
return await client return await client
} }
} }
@ -203,24 +194,6 @@ function createModelPersistence (workspace: string): TxPersistenceStore | undefi
} }
} }
async function hookClient (client: Promise<AccountClient>): Promise<AccountClient> {
const hook = getMetadata(clientPlugin.metadata.ClientHook)
if (hook !== undefined) {
const hookProc = await getResource(hook)
const _client = client
client = new Promise((resolve, reject) => {
_client
.then((res) => {
resolve(hookProc(res))
})
.catch((err) => {
reject(err)
})
})
}
return await client
}
function getWSFromToken (token: string): string { function getWSFromToken (token: string): string {
const parts = token.split('.') const parts = token.split('.')

View File

@ -22,11 +22,6 @@ import { Metadata, plugin } from '@hcengineering/platform'
*/ */
export const clientId = 'client' as Plugin export const clientId = 'client' as Plugin
/**
* @public
*/
export type ClientHook = (client: AccountClient) => Promise<AccountClient>
/** /**
* @public * @public
*/ */
@ -74,7 +69,6 @@ export type ClientFactory = (
export default plugin(clientId, { export default plugin(clientId, {
metadata: { metadata: {
ClientHook: '' as Metadata<Resource<ClientHook>>,
ClientSocketFactory: '' as Metadata<ClientSocketFactory>, ClientSocketFactory: '' as Metadata<ClientSocketFactory>,
FilterModel: '' as Metadata<boolean>, FilterModel: '' as Metadata<boolean>,
ExtraPlugins: '' as Metadata<Plugin[]>, ExtraPlugins: '' as Metadata<Plugin[]>,

View File

@ -194,7 +194,7 @@ async function createControlledDoc (
const success = await ops.commit() const success = await ops.commit()
return { seqNumber, success } return { seqNumber, success: success.result }
} }
export async function createDocumentTemplate ( export async function createDocumentTemplate (
@ -327,7 +327,7 @@ export async function createDocumentTemplate (
const success = await ops.commit() const success = await ops.commit()
return { seqNumber, success } return { seqNumber, success: success.result }
} }
export function getCollaborativeDocForDocument ( export function getCollaborativeDocForDocument (

View File

@ -13,20 +13,15 @@
// limitations under the License. // limitations under the License.
// //
import core, { import {
DOMAIN_MODEL, DOMAIN_MODEL,
cutObjectArray, cutObjectArray,
type Account,
type AccountClient,
type Class, type Class,
type Client, type Client,
type Doc, type Doc,
type DocumentQuery, type DocumentQuery,
type FindOptions, type FindOptions,
type FindResult, type FindResult,
type Hierarchy,
type MeasureDoneOperation,
type ModelDb,
type Ref, type Ref,
type SearchOptions, type SearchOptions,
type SearchQuery, type SearchQuery,
@ -35,13 +30,10 @@ import core, {
type TxResult, type TxResult,
type WithLookup type WithLookup
} from '@hcengineering/core' } from '@hcengineering/core'
import { devModelId } from '@hcengineering/devmodel'
import { Builder } from '@hcengineering/model'
import { getMetadata, type IntlString, type Resources } from '@hcengineering/platform' import { getMetadata, type IntlString, type Resources } from '@hcengineering/platform'
import { addTxListener } from '@hcengineering/presentation'
import type { ClientHook } from '@hcengineering/presentation/src/plugin'
import { testing } from '@hcengineering/ui' import { testing } from '@hcengineering/ui'
import view from '@hcengineering/view'
import workbench from '@hcengineering/workbench'
import ModelView from './components/ModelView.svelte'
import devmodel from './plugin' import devmodel from './plugin'
export interface TxWitHResult { export interface TxWitHResult {
@ -57,48 +49,46 @@ export interface QueryWithResult {
findOne: boolean findOne: boolean
} }
class ModelClient implements AccountClient { export class PresentationClientHook implements ClientHook {
notifyEnabled = true notifyEnabled = true
constructor (readonly client: AccountClient) { constructor () {
this.notifyEnabled = (localStorage.getItem('#platform.notification.logging') ?? 'true') === 'true' this.notifyEnabled = (localStorage.getItem('#platform.notification.logging') ?? 'true') === 'true'
client.notify = (...tx) => { addTxListener((...tx) => {
this.notify?.(...tx)
if (this.notifyEnabled) { if (this.notifyEnabled) {
console.debug( console.debug(
'devmodel# notify=>', 'devmodel# notify=>',
testing ? JSON.stringify(cutObjectArray(tx)).slice(0, 160) : tx.length === 1 ? tx[0] : tx testing ? JSON.stringify(cutObjectArray(tx)).slice(0, 160) : tx.length === 1 ? tx[0] : tx
) )
} }
})
}
stackLine (): string {
const stack = (new Error().stack ?? '').split('\n')
let candidate = ''
for (let l of stack) {
l = l.trim()
if (l.includes('.svelte')) {
return l
}
if (l.includes('plugins/') && !l.includes('devmodel-resources/') && l.includes('.ts') && candidate === '') {
candidate = l
}
} }
} return candidate
async measure (operationName: string): Promise<MeasureDoneOperation> {
return await this.client.measure(operationName)
}
notify?: (...tx: Tx[]) => void
getHierarchy (): Hierarchy {
return this.client.getHierarchy()
}
getModel (): ModelDb {
return this.client.getModel()
}
async getAccount (): Promise<Account> {
return await this.client.getAccount()
} }
async findOne<T extends Doc>( async findOne<T extends Doc>(
client: Client,
_class: Ref<Class<T>>, _class: Ref<Class<T>>,
query: DocumentQuery<T>, query: DocumentQuery<T>,
options?: FindOptions<T> options?: FindOptions<T>
): Promise<WithLookup<T> | undefined> { ): Promise<WithLookup<T> | undefined> {
const startTime = Date.now() const startTime = Date.now()
const isModel = this.getHierarchy().findDomain(_class) === DOMAIN_MODEL const isModel = client.getHierarchy().findDomain(_class) === DOMAIN_MODEL
const result = await this.client.findOne(_class, query, options) const result = await client.findOne(_class, query, options)
if (this.notifyEnabled && !isModel) { if (this.notifyEnabled && !isModel) {
console.debug( console.debug(
'devmodel# findOne=>', 'devmodel# findOne=>',
@ -108,22 +98,24 @@ class ModelClient implements AccountClient {
'result => ', 'result => ',
testing ? JSON.stringify(cutObjectArray(result)) : result, testing ? JSON.stringify(cutObjectArray(result)) : result,
' =>model', ' =>model',
this.client.getModel(), client.getModel(),
getMetadata(devmodel.metadata.DevModel), getMetadata(devmodel.metadata.DevModel),
Date.now() - startTime Date.now() - startTime,
this.stackLine()
) )
} }
return result return result
} }
async findAll<T extends Doc>( async findAll<T extends Doc>(
client: Client,
_class: Ref<Class<T>>, _class: Ref<Class<T>>,
query: DocumentQuery<T>, query: DocumentQuery<T>,
options?: FindOptions<T> options?: FindOptions<T>
): Promise<FindResult<T>> { ): Promise<FindResult<T>> {
const startTime = Date.now() const startTime = Date.now()
const isModel = this.getHierarchy().findDomain(_class) === DOMAIN_MODEL const isModel = client.getHierarchy().findDomain(_class) === DOMAIN_MODEL
const result = await this.client.findAll(_class, query, options) const result = await client.findAll(_class, query, options)
if (this.notifyEnabled && !isModel) { if (this.notifyEnabled && !isModel) {
console.debug( console.debug(
'devmodel# findAll=>', 'devmodel# findAll=>',
@ -133,17 +125,18 @@ class ModelClient implements AccountClient {
'result => ', 'result => ',
testing ? JSON.stringify(cutObjectArray(result)).slice(0, 160) : result, testing ? JSON.stringify(cutObjectArray(result)).slice(0, 160) : result,
' =>model', ' =>model',
this.client.getModel(), client.getModel(),
getMetadata(devmodel.metadata.DevModel), getMetadata(devmodel.metadata.DevModel),
Date.now() - startTime, Date.now() - startTime,
JSON.stringify(result).length JSON.stringify(result).length,
this.stackLine()
) )
} }
return result return result
} }
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> { async searchFulltext (client: Client, query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
const result = await this.client.searchFulltext(query, options) const result = await client.searchFulltext(query, options)
if (this.notifyEnabled) { if (this.notifyEnabled) {
console.debug( console.debug(
'devmodel# searchFulltext=>', 'devmodel# searchFulltext=>',
@ -156,71 +149,25 @@ class ModelClient implements AccountClient {
return result return result
} }
async tx (tx: Tx): Promise<TxResult> { async tx (client: Client, tx: Tx): Promise<TxResult> {
const startTime = Date.now() const startTime = Date.now()
const result = await this.client.tx(tx) const result = await client.tx(tx)
if (this.notifyEnabled) { if (this.notifyEnabled) {
console.debug( console.debug(
'devmodel# tx=>', 'devmodel# tx=>',
testing ? JSON.stringify(cutObjectArray(tx)).slice(0, 160) : tx, testing ? JSON.stringify(cutObjectArray(tx)).slice(0, 160) : tx,
result, result,
getMetadata(devmodel.metadata.DevModel), getMetadata(devmodel.metadata.DevModel),
Date.now() - startTime Date.now() - startTime,
this.stackLine()
) )
} }
return result return result
} }
async close (): Promise<void> {
await this.client.close()
}
}
export async function Hook (client: AccountClient): Promise<Client> {
console.debug('devmodel# Client HOOKED by DevModel')
// Client is alive here, we could hook with some model extensions special for DevModel plugin.
const builder = new Builder()
builder.createDoc(
workbench.class.Application,
core.space.Model,
{
label: 'DevModel' as IntlString,
icon: view.icon.DevModel,
alias: devModelId,
hidden: false,
navigatorModel: {
spaces: [],
specials: [
{
label: 'Transactions' as IntlString,
icon: view.icon.Table,
id: 'transactions',
component: devmodel.component.ModelView
}
]
}
},
devmodel.ids.DevModelApp
)
const model = client.getModel()
for (const tx of builder.getTxes()) {
await model.tx(tx)
}
return new ModelClient(client)
} }
export function toIntl (value: string): IntlString { export function toIntl (value: string): IntlString {
return value as IntlString return value as IntlString
} }
export default async (): Promise<Resources> => ({ export default async (): Promise<Resources> => ({})
component: {
ModelView
},
hook: {
Hook
}
})

View File

@ -13,8 +13,7 @@
// limitations under the License. // limitations under the License.
// //
import { ClientHook } from '@hcengineering/client' import type { Asset, Metadata, Plugin } from '@hcengineering/platform'
import type { Asset, Metadata, Plugin, Resource } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform'
import type { AnyComponent } from '@hcengineering/ui' import type { AnyComponent } from '@hcengineering/ui'
@ -32,9 +31,6 @@ export default plugin(devModelId, {
TransactionView: '' as AnyComponent, TransactionView: '' as AnyComponent,
NotificationsView: '' as AnyComponent NotificationsView: '' as AnyComponent
}, },
hook: {
Hook: '' as Resource<ClientHook>
},
metadata: { metadata: {
DevModel: '' as Metadata<any> DevModel: '' as Metadata<any>
} }

View File

@ -13,37 +13,36 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import activity, { ActivityMessage } from '@hcengineering/activity'
import chunter from '@hcengineering/chunter'
import { getCurrentAccount, groupByArray, IdMap, Ref, SortingOrder } from '@hcengineering/core'
import { DocNotifyContext, InboxNotification, notificationId } from '@hcengineering/notification' import { DocNotifyContext, InboxNotification, notificationId } from '@hcengineering/notification'
import { ActionContext, createQuery, getClient } from '@hcengineering/presentation' import { ActionContext, createQuery, getClient } from '@hcengineering/presentation'
import view, { decodeObjectURI } from '@hcengineering/view'
import { import {
AnyComponent, AnyComponent,
Component, Component,
defineSeparators, defineSeparators,
deviceOptionsStore as deviceInfo,
Label, Label,
location as locationStore,
Location, Location,
location as locationStore,
restoreLocation, restoreLocation,
Scroller, Scroller,
Separator, Separator,
TabItem, TabItem,
TabList, TabList
deviceOptionsStore as deviceInfo
} from '@hcengineering/ui' } from '@hcengineering/ui'
import chunter from '@hcengineering/chunter' import view, { decodeObjectURI } from '@hcengineering/view'
import activity, { ActivityMessage } from '@hcengineering/activity'
import { get } from 'svelte/store'
import { translate } from '@hcengineering/platform'
import { getCurrentAccount, groupByArray, IdMap, Ref, SortingOrder } from '@hcengineering/core'
import { parseLinkId } from '@hcengineering/view-resources' import { parseLinkId } from '@hcengineering/view-resources'
import { get } from 'svelte/store'
import { InboxNotificationsClientImpl } from '../../inboxNotificationsClient' import { InboxNotificationsClientImpl } from '../../inboxNotificationsClient'
import SettingsButton from './SettingsButton.svelte'
import { getDisplayInboxData, resetInboxContext, resolveLocation, selectInboxContext } from '../../utils'
import { InboxData, InboxNotificationsFilter } from '../../types'
import InboxGroupedListView from './InboxGroupedListView.svelte'
import notification from '../../plugin' import notification from '../../plugin'
import { InboxData, InboxNotificationsFilter } from '../../types'
import { getDisplayInboxData, resetInboxContext, resolveLocation, selectInboxContext } from '../../utils'
import InboxGroupedListView from './InboxGroupedListView.svelte'
import InboxMenuButton from './InboxMenuButton.svelte' import InboxMenuButton from './InboxMenuButton.svelte'
import SettingsButton from './SettingsButton.svelte'
const client = getClient() const client = getClient()
const hierarchy = client.getHierarchy() const hierarchy = client.getHierarchy()
@ -248,8 +247,7 @@
const contextNotifications = $notificationsByContextStore.get(selectedContext._id) ?? [] const contextNotifications = $notificationsByContextStore.get(selectedContext._id) ?? []
const doneOp = await getClient().measure('readNotifications') const ops = getClient().apply(selectedContext._id, 'readNotifications')
const ops = getClient().apply(selectedContext._id)
try { try {
await inboxClient.readNotifications( await inboxClient.readNotifications(
ops, ops,
@ -261,7 +259,6 @@
) )
} finally { } finally {
await ops.commit() await ops.commit()
await doneOp()
} }
} }

View File

@ -242,8 +242,7 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient {
} }
async archiveAllNotifications (): Promise<void> { async archiveAllNotifications (): Promise<void> {
const doneOp = await getClient().measure('archiveAllNotifications') const ops = getClient().apply(generateId(), 'archiveAllNotifications')
const ops = getClient().apply(generateId())
try { try {
const inboxNotifications = await ops.findAll( const inboxNotifications = await ops.findAll(
@ -264,13 +263,11 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient {
} }
} finally { } finally {
await ops.commit() await ops.commit()
await doneOp()
} }
} }
async readAllNotifications (): Promise<void> { async readAllNotifications (): Promise<void> {
const doneOp = await getClient().measure('readAllNotifications') const ops = getClient().apply(generateId(), 'readAllNotifications')
const ops = getClient().apply(generateId())
try { try {
const inboxNotifications = await ops.findAll( const inboxNotifications = await ops.findAll(
@ -291,13 +288,11 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient {
} }
} finally { } finally {
await ops.commit() await ops.commit()
await doneOp()
} }
} }
async unreadAllNotifications (): Promise<void> { async unreadAllNotifications (): Promise<void> {
const doneOp = await getClient().measure('unreadAllNotifications') const ops = getClient().apply(generateId(), 'unreadAllNotifications')
const ops = getClient().apply(generateId())
try { try {
const inboxNotifications = await ops.findAll( const inboxNotifications = await ops.findAll(
@ -332,7 +327,6 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient {
} }
} finally { } finally {
await ops.commit() await ops.commit()
await doneOp()
} }
} }
} }

View File

@ -49,21 +49,21 @@ import { MessageBox, getClient } from '@hcengineering/presentation'
import { import {
getCurrentLocation, getCurrentLocation,
getLocation, getLocation,
locationStorageKeyId,
navigate, navigate,
parseLocation, parseLocation,
showPopup, showPopup,
type Location, type Location,
type ResolvedLocation, type ResolvedLocation
locationStorageKeyId
} from '@hcengineering/ui' } from '@hcengineering/ui'
import { get, writable } from 'svelte/store' import { get, writable } from 'svelte/store'
import chunter, { type ThreadMessage } from '@hcengineering/chunter'
import { getMetadata } from '@hcengineering/platform'
import { decodeObjectURI, encodeObjectURI, type LinkIdProvider } from '@hcengineering/view'
import { getObjectLinkId } from '@hcengineering/view-resources'
import { InboxNotificationsClientImpl } from './inboxNotificationsClient' import { InboxNotificationsClientImpl } from './inboxNotificationsClient'
import { type InboxData, type InboxNotificationsFilter } from './types' import { type InboxData, type InboxNotificationsFilter } from './types'
import { getMetadata } from '@hcengineering/platform'
import { getObjectLinkId } from '@hcengineering/view-resources'
import { decodeObjectURI, encodeObjectURI, type LinkIdProvider } from '@hcengineering/view'
import chunter, { type ThreadMessage } from '@hcengineering/chunter'
export async function hasDocNotifyContextPinAction (docNotifyContext: DocNotifyContext): Promise<boolean> { export async function hasDocNotifyContextPinAction (docNotifyContext: DocNotifyContext): Promise<boolean> {
if (docNotifyContext.hidden) { if (docNotifyContext.hidden) {
@ -107,8 +107,7 @@ export async function readNotifyContext (doc: DocNotifyContext): Promise<void> {
const inboxClient = InboxNotificationsClientImpl.getClient() const inboxClient = InboxNotificationsClientImpl.getClient()
const inboxNotifications = get(inboxClient.inboxNotificationsByContext).get(doc._id) ?? [] const inboxNotifications = get(inboxClient.inboxNotificationsByContext).get(doc._id) ?? []
const doneOp = await getClient().measure('readNotifyContext') const ops = getClient().apply(doc._id, 'readNotifyContext')
const ops = getClient().apply(doc._id)
try { try {
await inboxClient.readNotifications( await inboxClient.readNotifications(
ops, ops,
@ -117,7 +116,6 @@ export async function readNotifyContext (doc: DocNotifyContext): Promise<void> {
await ops.update(doc, { lastViewedTimestamp: Date.now() }) await ops.update(doc, { lastViewedTimestamp: Date.now() })
} finally { } finally {
await ops.commit() await ops.commit()
await doneOp()
} }
} }
@ -133,8 +131,7 @@ export async function unReadNotifyContext (doc: DocNotifyContext): Promise<void>
return return
} }
const doneOp = await getClient().measure('unReadNotifyContext') const ops = getClient().apply(doc._id, 'unReadNotifyContext')
const ops = getClient().apply(doc._id)
try { try {
await inboxClient.unreadNotifications( await inboxClient.unreadNotifications(
@ -154,7 +151,6 @@ export async function unReadNotifyContext (doc: DocNotifyContext): Promise<void>
} }
} finally { } finally {
await ops.commit() await ops.commit()
await doneOp()
} }
} }
@ -166,8 +162,7 @@ export async function archiveContextNotifications (doc?: DocNotifyContext): Prom
return return
} }
const doneOp = await getClient().measure('archiveContextNotifications') const ops = getClient().apply(doc._id, 'archiveContextNotifications')
const ops = getClient().apply(doc._id)
try { try {
const notifications = await ops.findAll( const notifications = await ops.findAll(
@ -182,7 +177,6 @@ export async function archiveContextNotifications (doc?: DocNotifyContext): Prom
await ops.update(doc, { lastViewedTimestamp: Date.now() }) await ops.update(doc, { lastViewedTimestamp: Date.now() })
} finally { } finally {
await ops.commit() await ops.commit()
await doneOp()
} }
} }
@ -194,8 +188,7 @@ export async function unarchiveContextNotifications (doc?: DocNotifyContext): Pr
return return
} }
const doneOp = await getClient().measure('unarchiveContextNotifications') const ops = getClient().apply(doc._id, 'unarchiveContextNotifications')
const ops = getClient().apply(doc._id)
try { try {
const notifications = await ops.findAll( const notifications = await ops.findAll(
@ -209,7 +202,6 @@ export async function unarchiveContextNotifications (doc?: DocNotifyContext): Pr
} }
} finally { } finally {
await ops.commit() await ops.commit()
await doneOp()
} }
} }

View File

@ -442,10 +442,8 @@
// TODO: We need a measure client and mark all operations with it as measure under one root, // TODO: We need a measure client and mark all operations with it as measure under one root,
// to prevent other operations to infer our measurement. // to prevent other operations to infer our measurement.
const doneOp = await getClient().measure('tracker.createIssue')
try { try {
const operations = client.apply(_id) const operations = client.apply(_id, 'tracker.createIssue')
const lastOne = await client.findOne<Issue>( const lastOne = await client.findOne<Issue>(
tracker.class.Issue, tracker.class.Issue,
@ -533,7 +531,7 @@
} }
} }
await operations.commit() const result = await operations.commit()
await descriptionBox?.createAttachments(_id) await descriptionBox?.createAttachments(_id)
const parents: IssueParentInfo[] = const parents: IssueParentInfo[] =
@ -565,15 +563,12 @@
descriptionBox?.removeDraft(false) descriptionBox?.removeDraft(false)
isAssigneeTouched = false isAssigneeTouched = false
const d1 = Date.now() const d1 = Date.now()
void doneOp().then((res) => { console.log('createIssue measure', result, Date.now() - d1)
console.log('createIssue measure', res, Date.now() - d1)
})
} catch (err: any) { } catch (err: any) {
resetObject() resetObject()
draftController.remove() draftController.remove()
descriptionBox?.removeDraft(false) descriptionBox?.removeDraft(false)
console.error(err) console.error(err)
await doneOp() // Complete in case of error
Analytics.handleError(err) Analytics.handleError(err)
} }
} }

View File

@ -235,7 +235,7 @@
await ops.createDoc(tracker.class.Project, core.space.Space, { ...projectData, type: typeId }, projectId) await ops.createDoc(tracker.class.Project, core.space.Space, { ...projectData, type: typeId }, projectId)
const succeeded = await ops.commit() const succeeded = await ops.commit()
if (succeeded) { if (succeeded.result) {
// Add space type's mixin with roles assignments // Add space type's mixin with roles assignments
await client.createMixin( await client.createMixin(
projectId, projectId,

View File

@ -25,8 +25,8 @@ export async function issueIdentifierProvider (client: TxOperations, ref: Ref<Is
return object.identifier return object.identifier
} }
export async function issueTitleProvider (client: TxOperations, ref: Ref<Doc>): Promise<string> { export async function issueTitleProvider (client: TxOperations, ref: Ref<Doc>, doc?: Issue): Promise<string> {
const object = await client.findOne(tracker.class.Issue, { _id: ref as Ref<Issue> }) const object = doc ?? (await client.findOne(tracker.class.Issue, { _id: ref as Ref<Issue> }))
if (object === undefined) { if (object === undefined) {
return '' return ''

View File

@ -652,7 +652,7 @@ async function ActivityReferenceCreate (tx: TxCUD<Doc>, control: TriggerControl)
) )
if (txes.length !== 0) { if (txes.length !== 0) {
await control.apply(txes, true) await control.apply(txes)
} }
return [] return []
@ -699,7 +699,7 @@ async function ActivityReferenceUpdate (tx: TxCUD<Doc>, control: TriggerControl)
) )
if (txes.length !== 0) { if (txes.length !== 0) {
await control.apply(txes, true) await control.apply(txes)
} }
return [] return []
@ -723,7 +723,7 @@ async function ActivityReferenceRemove (tx: Tx, control: TriggerControl): Promis
const txes: Tx[] = await getRemoveActivityReferenceTxes(control, txFactory, ctx.objectId) const txes: Tx[] = await getRemoveActivityReferenceTxes(control, txFactory, ctx.objectId)
if (txes.length !== 0) { if (txes.length !== 0) {
await control.apply(txes, true) await control.apply(txes)
} }
} }

View File

@ -13,7 +13,9 @@
// limitations under the License. // limitations under the License.
// //
import activity, { ActivityMessage, ActivityReference } from '@hcengineering/activity'
import chunter, { Channel, ChatMessage, chunterId, ChunterSpace, ThreadMessage } from '@hcengineering/chunter' import chunter, { Channel, ChatMessage, chunterId, ChunterSpace, ThreadMessage } from '@hcengineering/chunter'
import { Person, PersonAccount } from '@hcengineering/contact'
import core, { import core, {
Account, Account,
AttachedDoc, AttachedDoc,
@ -38,14 +40,12 @@ import notification, { Collaborators, NotificationContent } from '@hcengineering
import { getMetadata, IntlString } from '@hcengineering/platform' import { getMetadata, IntlString } from '@hcengineering/platform'
import serverCore, { TriggerControl } from '@hcengineering/server-core' import serverCore, { TriggerControl } from '@hcengineering/server-core'
import { import {
createCollaboratorNotifications,
getDocCollaborators, getDocCollaborators,
getMixinTx, getMixinTx
createCollaboratorNotifications
} from '@hcengineering/server-notification-resources' } from '@hcengineering/server-notification-resources'
import { workbenchId } from '@hcengineering/workbench'
import { stripTags } from '@hcengineering/text' import { stripTags } from '@hcengineering/text'
import { Person, PersonAccount } from '@hcengineering/contact' import { workbenchId } from '@hcengineering/workbench'
import activity, { ActivityMessage, ActivityReference } from '@hcengineering/activity'
import { NOTIFICATION_BODY_SIZE } from '@hcengineering/server-notification' import { NOTIFICATION_BODY_SIZE } from '@hcengineering/server-notification'
@ -258,13 +258,24 @@ async function OnThreadMessageDeleted (tx: Tx, control: TriggerControl): Promise
* @public * @public
*/ */
export async function ChunterTrigger (tx: TxCUD<Doc>, control: TriggerControl): Promise<Tx[]> { export async function ChunterTrigger (tx: TxCUD<Doc>, control: TriggerControl): Promise<Tx[]> {
const res = await Promise.all([ const res: Tx[] = []
OnThreadMessageCreated(tx, control), res.push(
OnThreadMessageDeleted(tx, control), ...(await control.ctx.with('OnThreadMessageCreated', {}, async (ctx) => await OnThreadMessageCreated(tx, control)))
OnCollaboratorsChanged(tx as TxMixin<Doc, Collaborators>, control), )
OnChatMessageCreated(tx, control) res.push(
]) ...(await control.ctx.with('OnThreadMessageDeleted', {}, async (ctx) => await OnThreadMessageDeleted(tx, control)))
return res.flat() )
res.push(
...(await control.ctx.with(
'OnCollaboratorsChanged',
{},
async (ctx) => await OnCollaboratorsChanged(tx as TxMixin<Doc, Collaborators>, control)
))
)
res.push(
...(await control.ctx.with('OnChatMessageCreated', {}, async (ctx) => await OnChatMessageCreated(tx, control)))
)
return res
} }
/** /**
@ -393,7 +404,7 @@ async function OnChannelMembersChanged (tx: TxUpdateDoc<Channel>, control: Trigg
lastViewedTimestamp: tx.modifiedOn lastViewedTimestamp: tx.modifiedOn
}) })
await control.apply([createTx], true) await control.apply([createTx])
} else { } else {
const updateTx = control.txFactory.createTxUpdateDoc(context._class, context.space, context._id, { const updateTx = control.txFactory.createTxUpdateDoc(context._class, context.space, context._id, {
hidden: false, hidden: false,

View File

@ -284,7 +284,7 @@ async function createDocumentTrainingRequest (doc: ControlledDocument, control:
// Force space to make transaction persistent and raise notifications // Force space to make transaction persistent and raise notifications
resTx.space = core.space.Tx resTx.space = core.space.Tx
await control.apply([resTx], true) await control.apply([resTx])
return [] return []
} }
@ -368,7 +368,7 @@ export async function OnDocPlannedEffectiveDateChanged (
if (tx.operations.plannedEffectiveDate === 0 && doc.controlledState === ControlledDocumentState.Approved) { if (tx.operations.plannedEffectiveDate === 0 && doc.controlledState === ControlledDocumentState.Approved) {
// Create with not derived tx factory in order for notifications to work // Create with not derived tx factory in order for notifications to work
const factory = new TxFactory(control.txFactory.account) const factory = new TxFactory(control.txFactory.account)
await control.apply([makeDocEffective(doc, factory)], true) await control.apply([makeDocEffective(doc, factory)])
} }
return [] return []
@ -385,7 +385,7 @@ export async function OnDocApprovalRequestApproved (
// Create with not derived tx factory in order for notifications to work // Create with not derived tx factory in order for notifications to work
const factory = new TxFactory(control.txFactory.account) const factory = new TxFactory(control.txFactory.account)
await control.apply([makeDocEffective(doc, factory)], true) await control.apply([makeDocEffective(doc, factory)])
// make doc effective immediately // make doc effective immediately
return [] return []

View File

@ -97,9 +97,10 @@ async function createUserInfo (acc: Ref<Account>, control: TriggerControl): Prom
query: { person } query: { person }
} }
], ],
[tx] [tx],
'createUserInfo'
) )
await control.apply([ptx], true) await control.apply([ptx])
return [] return []
} }
@ -114,7 +115,7 @@ async function removeUserInfo (acc: Ref<Account>, control: TriggerControl): Prom
const person = account.person const person = account.person
const infos = await control.findAll(love.class.ParticipantInfo, { person }) const infos = await control.findAll(love.class.ParticipantInfo, { person })
for (const info of infos) { for (const info of infos) {
await control.apply([control.txFactory.createTxRemoveDoc(info._class, info.space, info._id)], true) await control.apply([control.txFactory.createTxRemoveDoc(info._class, info.space, info._id)])
} }
} }

View File

@ -15,6 +15,7 @@
// //
import activity, { ActivityMessage, DocUpdateMessage } from '@hcengineering/activity' import activity, { ActivityMessage, DocUpdateMessage } from '@hcengineering/activity'
import { Analytics } from '@hcengineering/analytics'
import chunter, { ChatMessage } from '@hcengineering/chunter' import chunter, { ChatMessage } from '@hcengineering/chunter'
import contact, { import contact, {
type AvatarInfo, type AvatarInfo,
@ -42,6 +43,7 @@ import core, {
RefTo, RefTo,
Space, Space,
Timestamp, Timestamp,
toIdMap,
Tx, Tx,
TxCollectionCUD, TxCollectionCUD,
TxCreateDoc, TxCreateDoc,
@ -75,12 +77,11 @@ import serverNotification, {
getPersonAccountById, getPersonAccountById,
NOTIFICATION_BODY_SIZE NOTIFICATION_BODY_SIZE
} from '@hcengineering/server-notification' } from '@hcengineering/server-notification'
import serverView from '@hcengineering/server-view'
import { stripTags } from '@hcengineering/text' import { stripTags } from '@hcengineering/text'
import { encodeObjectURI } from '@hcengineering/view'
import { workbenchId } from '@hcengineering/workbench' import { workbenchId } from '@hcengineering/workbench'
import webpush, { WebPushError } from 'web-push' import webpush, { WebPushError } from 'web-push'
import { encodeObjectURI } from '@hcengineering/view'
import serverView from '@hcengineering/server-view'
import { Analytics } from '@hcengineering/analytics'
import { Content, NotifyParams, NotifyResult, UserInfo } from './types' import { Content, NotifyParams, NotifyResult, UserInfo } from './types'
import { import {
@ -396,14 +397,24 @@ export async function pushInboxNotifications (
hidden: false, hidden: false,
lastUpdateTimestamp: shouldUpdateTimestamp ? modifiedOn : undefined lastUpdateTimestamp: shouldUpdateTimestamp ? modifiedOn : undefined
}) })
await control.apply([createContextTx], true, [account.email]) await control.apply([createContextTx])
control.operationContext.derived.targets['docNotifyContext' + createContextTx._id] = (it) => {
if (it._id === createContextTx._id) {
return [account.email]
}
}
docNotifyContextId = createContextTx.objectId docNotifyContextId = createContextTx.objectId
} else { } else {
if (shouldUpdateTimestamp && context.lastUpdateTimestamp !== modifiedOn) { if (shouldUpdateTimestamp && context.lastUpdateTimestamp !== modifiedOn) {
const updateTx = control.txFactory.createTxUpdateDoc(context._class, context.space, context._id, { const updateTx = control.txFactory.createTxUpdateDoc(context._class, context.space, context._id, {
lastUpdateTimestamp: modifiedOn lastUpdateTimestamp: modifiedOn
}) })
await control.apply([updateTx], true, [account.email]) await control.apply([updateTx])
control.operationContext.derived.targets['docNotifyContext' + updateTx._id] = (it) => {
if (it._id === updateTx._id) {
return [account.email]
}
}
} }
docNotifyContextId = context._id docNotifyContextId = context._id
} }
@ -636,7 +647,7 @@ async function sendPushToSubscription (
console.log('Cannot send push notification to', targetUser, err) console.log('Cannot send push notification to', targetUser, err)
if (err instanceof WebPushError && err.body.includes('expired')) { if (err instanceof WebPushError && err.body.includes('expired')) {
const tx = control.txFactory.createTxRemoveDoc(subscription._class, subscription.space, subscription._id) const tx = control.txFactory.createTxRemoveDoc(subscription._class, subscription.space, subscription._id)
await control.apply([tx], true) await control.apply([tx])
} }
} }
} }
@ -1286,7 +1297,14 @@ async function applyUserTxes (
if (account !== undefined) { if (account !== undefined) {
cache.set(account._id, account) cache.set(account._id, account)
await control.apply(txs, true, [account.email]) await control.apply(txs)
const m1 = toIdMap(txes)
control.operationContext.derived.targets.docNotifyContext = (it) => {
if (m1.has(it._id)) {
return [account.email]
}
}
} }
} }

View File

@ -99,7 +99,7 @@ async function OnRequestUpdate (tx: TxCollectionCUD<Doc, Request>, control: Trig
} }
if (applyTxes.length > 0) { if (applyTxes.length > 0) {
await control.apply(applyTxes, true) await control.apply(applyTxes)
} }
return [] return []

View File

@ -107,7 +107,7 @@ export async function OnWorkSlotCreate (tx: Tx, control: TriggerControl): Promis
issue.collection, issue.collection,
innerTx innerTx
) )
await control.apply([outerTx], true) await control.apply([outerTx])
return [] return []
} }
} }
@ -153,7 +153,7 @@ export async function OnToDoRemove (tx: Tx, control: TriggerControl): Promise<Tx
issue.collection, issue.collection,
innerTx innerTx
) )
await control.apply([outerTx], true) await control.apply([outerTx])
return [] return []
} }
} }
@ -304,7 +304,7 @@ export async function OnToDoUpdate (tx: Tx, control: TriggerControl): Promise<Tx
if (funcs !== undefined) { if (funcs !== undefined) {
const func = await getResource(funcs.onDone) const func = await getResource(funcs.onDone)
const todoRes = await func(control, resEvents, todo) const todoRes = await func(control, resEvents, todo)
await control.apply(todoRes, true) await control.apply(todoRes)
} }
return res return res
} }
@ -447,7 +447,7 @@ async function createIssueHandler (issue: Issue, control: TriggerControl): Promi
if (status.category === task.statusCategory.Active || status.category === task.statusCategory.ToDo) { if (status.category === task.statusCategory.Active || status.category === task.statusCategory.ToDo) {
const tx = await getCreateToDoTx(issue, issue.assignee, control) const tx = await getCreateToDoTx(issue, issue.assignee, control)
if (tx !== undefined) { if (tx !== undefined) {
await control.apply([tx], true) await control.apply([tx])
} }
} }
} }
@ -561,7 +561,7 @@ async function changeIssueStatusHandler (
if (todos.length === 0) { if (todos.length === 0) {
const tx = await getCreateToDoTx(issue, issue.assignee, control) const tx = await getCreateToDoTx(issue, issue.assignee, control)
if (tx !== undefined) { if (tx !== undefined) {
await control.apply([tx], true) await control.apply([tx])
} }
} }
} }

View File

@ -24,9 +24,10 @@ import core, {
TxProcessor, TxProcessor,
cutObjectArray, cutObjectArray,
toFindResult, toFindResult,
type Branding,
type Account, type Account,
type AttachedDoc, type AttachedDoc,
type Branding,
type BroadcastTargets,
type Class, type Class,
type Client, type Client,
type Collection, type Collection,
@ -50,6 +51,7 @@ import core, {
type Timestamp, type Timestamp,
type Tx, type Tx,
type TxApplyIf, type TxApplyIf,
type TxApplyResult,
type TxCUD, type TxCUD,
type TxCollectionCUD, type TxCollectionCUD,
type TxRemoveDoc, type TxRemoveDoc,
@ -612,7 +614,7 @@ export class TServerStorage implements ServerStorage {
return result return result
} }
private async broadcastCtx (derived: SessionOperationContext['derived']): Promise<void> { private async broadcastCtx (derived: Tx[], targets?: BroadcastTargets): Promise<void> {
const toSendTarget = new Map<string, Tx[]>() const toSendTarget = new Map<string, Tx[]>()
const getTxes = (key: string): Tx[] => { const getTxes = (key: string): Tx[] => {
@ -626,16 +628,23 @@ export class TServerStorage implements ServerStorage {
// Put current user as send target // Put current user as send target
for (const txd of derived) { for (const txd of derived) {
if (txd.target === undefined) { let target: string[] | undefined
for (const tt of Object.values(targets ?? {})) {
target = tt(txd)
if (target !== undefined) {
break
}
}
if (target === undefined) {
getTxes('') // Be sure we have empty one getTxes('') // Be sure we have empty one
// Also add to all other targeted sends // Also add to all other targeted sends
for (const v of toSendTarget.values()) { for (const v of toSendTarget.values()) {
v.push(...txd.derived) v.push(txd)
} }
} else { } else {
for (const t of txd.target) { for (const t of target) {
getTxes(t).push(...txd.derived) getTxes(t).push(txd)
} }
} }
} }
@ -708,7 +717,9 @@ export class TServerStorage implements ServerStorage {
) )
const moves = await ctx.with('process-move', {}, (ctx) => this.processMove(ctx.ctx, txes, findAll)) const moves = await ctx.with('process-move', {}, (ctx) => this.processMove(ctx.ctx, txes, findAll))
const triggerControl: Omit<TriggerControl, 'txFactory' | 'ctx' | 'result'> = { const applyTxes: Tx[] = []
const triggerControl: Omit<TriggerControl, 'txFactory' | 'ctx' | 'result' | 'apply'> = {
operationContext: ctx, operationContext: ctx,
removedMap, removedMap,
workspace: this.workspaceId, workspace: this.workspaceId,
@ -719,52 +730,73 @@ export class TServerStorage implements ServerStorage {
findAllCtx: findAll, findAllCtx: findAll,
modelDb: this.modelDb, modelDb: this.modelDb,
hierarchy: this.hierarchy, hierarchy: this.hierarchy,
apply: async (tx, broadcast, target) => { applyCtx: async (ctx, tx, needResult) => {
return await this.apply(ctx, tx, broadcast, target) if (needResult === true) {
}, return await this.apply(ctx, tx)
applyCtx: async (ctx, tx, broadcast, target) => { } else {
return await this.apply(ctx, tx, broadcast, target) applyTxes.push(...tx)
}
return {}
}, },
// Will create a live query if missing and return values immediately if already asked. // Will create a live query if missing and return values immediately if already asked.
queryFind: async (_class, query, options) => { queryFind: async (_class, query, options) => {
return await this.liveQuery.queryFind(_class, query, options) return await this.liveQuery.queryFind(_class, query, options)
} }
} }
const triggers = await ctx.with('process-triggers', {}, async (ctx) => { const triggers = await ctx.with(
const result: Tx[] = [] 'process-triggers',
const { transactions, performAsync } = await this.triggers.apply(ctx, txes, { {},
...triggerControl, async (ctx) => {
ctx: ctx.ctx, const result: Tx[] = []
findAll: fAll(ctx.ctx), const { transactions, performAsync } = await this.triggers.apply(ctx, txes, {
result ...triggerControl,
}) ctx: ctx.ctx,
result.push(...transactions) findAll: fAll(ctx.ctx),
result
})
result.push(...transactions)
if (performAsync !== undefined) { if (applyTxes.length > 0) {
const asyncTriggerProcessor = async (): Promise<void> => { await this.apply(ctx, applyTxes)
await ctx.with('process-async-triggers', {}, async (ctx) => { }
const sctx = ctx as SessionContext
const applyCtx: SessionContextImpl = new SessionContextImpl( if (performAsync !== undefined) {
ctx.ctx, const asyncTriggerProcessor = async (): Promise<void> => {
sctx.userEmail, await ctx.with(
sctx.sessionId, 'process-async-triggers',
sctx.admin, {},
[], async (ctx) => {
this.workspaceId, const sctx = ctx as SessionContext
this.options.branding const applyCtx: SessionContextImpl = new SessionContextImpl(
ctx.ctx,
sctx.userEmail,
sctx.sessionId,
sctx.admin,
{ txes: [], targets: {} },
this.workspaceId,
this.options.branding,
true
)
const result = await performAsync(applyCtx)
if (applyTxes.length > 0) {
await this.apply(applyCtx, applyTxes)
}
// We need to broadcast changes
await this.broadcastCtx(applyCtx.derived.txes.concat(result), applyCtx.derived.targets)
},
{ count: txes.length }
) )
const result = await performAsync(applyCtx) }
// We need to broadcast changes setTimeout(() => {
await this.broadcastCtx([{ derived: result }, ...applyCtx.derived]) void asyncTriggerProcessor()
}) })
} }
setTimeout(() => {
void asyncTriggerProcessor()
})
}
return result return result
}) },
{ count: txes.length }
)
const derived = [...removed, ...collections, ...moves, ...triggers] const derived = [...removed, ...collections, ...moves, ...triggers]
@ -835,8 +867,8 @@ export class TServerStorage implements ServerStorage {
return { passed, onEnd } return { passed, onEnd }
} }
async apply (ctx: SessionOperationContext, txes: Tx[], broadcast: boolean, target?: string[]): Promise<TxResult> { async apply (ctx: SessionOperationContext, txes: Tx[]): Promise<TxResult> {
return await this.processTxes(ctx, txes, broadcast, target) return await this.processTxes(ctx, txes)
} }
fillTxes (txes: Tx[], txToStore: Tx[], modelTx: Tx[], txToProcess: Tx[], applyTxes: Tx[]): void { fillTxes (txes: Tx[], txToStore: Tx[], modelTx: Tx[], txToProcess: Tx[], applyTxes: Tx[]): void {
@ -861,12 +893,7 @@ export class TServerStorage implements ServerStorage {
} }
} }
async processTxes ( async processTxes (ctx: SessionOperationContext, txes: Tx[]): Promise<TxResult> {
ctx: SessionOperationContext,
txes: Tx[],
broadcast: boolean,
target?: string[]
): Promise<TxResult> {
// store tx // store tx
const _findAll: ServerStorage['findAll'] = async <T extends Doc>( const _findAll: ServerStorage['findAll'] = async <T extends Doc>(
ctx: MeasureContext, ctx: MeasureContext,
@ -914,16 +941,25 @@ export class TServerStorage implements ServerStorage {
await this.triggers.tx(tx) await this.triggers.tx(tx)
await this.modelDb.tx(tx) await this.modelDb.tx(tx)
} }
await ctx.with('domain-tx', {}, async (ctx) => await this.getAdapter(DOMAIN_TX, true).tx(ctx.ctx, ...txToStore)) await ctx.with('domain-tx', {}, async (ctx) => await this.getAdapter(DOMAIN_TX, true).tx(ctx.ctx, ...txToStore), {
result.push(...(await ctx.with('apply', {}, (ctx) => this.routeTx(ctx.ctx, removedMap, ...txToProcess)))) count: txToStore.length
})
result.push(...(await ctx.with('apply', {}, (ctx) => this.routeTx(ctx.ctx, removedMap, ...txToProcess))), {
count: txToProcess.length
})
// invoke triggers and store derived objects // invoke triggers and store derived objects
derived.push(...(await this.processDerived(ctx, txToProcess, _findAll, removedMap))) derived.push(...(await this.processDerived(ctx, txToProcess, _findAll, removedMap)))
// index object // index object
await ctx.with('fulltext-tx', {}, async (ctx) => { await ctx.with(
await this.fulltext.tx(ctx.ctx, [...txToProcess, ...derived]) 'fulltext-tx',
}) {},
async (ctx) => {
await this.fulltext.tx(ctx.ctx, [...txToProcess, ...derived])
},
{ count: txToProcess.length + derived.length }
)
} catch (err: any) { } catch (err: any) {
ctx.ctx.error('error process tx', { error: err }) ctx.ctx.error('error process tx', { error: err })
Analytics.handleError(err) Analytics.handleError(err)
@ -933,16 +969,33 @@ export class TServerStorage implements ServerStorage {
p() p()
}) })
} }
if (derived.length > 0 && broadcast) { if (derived.length > 0) {
ctx.derived.push({ derived, target }) ctx.derived.txes.push(...derived)
} }
return result[0] return result[0]
} }
async tx (ctx: SessionOperationContext, tx: Tx): Promise<TxResult> { async tx (ctx: SessionOperationContext, tx: Tx): Promise<TxResult> {
return await ctx.with('client-tx', { _class: tx._class }, async (ctx) => { let measureName: string | undefined
return await this.processTxes(ctx, [tx], true) let st: number | undefined
}) if (tx._class === core.class.TxApplyIf && (tx as TxApplyIf).measureName !== undefined) {
measureName = (tx as TxApplyIf).measureName
st = Date.now()
}
const result = await ctx.with(
measureName !== undefined ? `📶 ${measureName}` : 'client-tx',
{ _class: tx._class },
async (ctx) => {
return await this.processTxes(ctx, [tx])
}
)
if (measureName !== undefined && st !== undefined) {
;(result as TxApplyResult).serverTime = Date.now() - st
}
return result
} }
find (ctx: MeasureContext, domain: Domain, recheck?: boolean): StorageIterator { find (ctx: MeasureContext, domain: Domain, recheck?: boolean): StorageIterator {

View File

@ -17,6 +17,7 @@
import core, { import core, {
TxFactory, TxFactory,
cutObjectArray, cutObjectArray,
groupByArray,
matchQuery, matchQuery,
type AttachedDoc, type AttachedDoc,
type Class, type Class,
@ -31,15 +32,18 @@ import core, {
type TxCreateDoc type TxCreateDoc
} from '@hcengineering/core' } from '@hcengineering/core'
import { Analytics } from '@hcengineering/analytics'
import { getResource, type Resource } from '@hcengineering/platform' import { getResource, type Resource } from '@hcengineering/platform'
import type { Trigger, TriggerControl, TriggerFunc } from './types' import type { Trigger, TriggerControl, TriggerFunc } from './types'
import { Analytics } from '@hcengineering/analytics'
import serverCore from './plugin' import serverCore from './plugin'
import type { SessionContextImpl } from './utils'
interface TriggerRecord { interface TriggerRecord {
query?: DocumentQuery<Tx> query?: DocumentQuery<Tx>
trigger: { op: TriggerFunc, resource: Resource<TriggerFunc>, isAsync: boolean } trigger: { op: TriggerFunc, resource: Resource<TriggerFunc>, isAsync: boolean }
arrays: boolean
} }
/** /**
* @public * @public
@ -64,7 +68,8 @@ export class Triggers {
this.triggers.push({ this.triggers.push({
query: match, query: match,
trigger: { op: func, resource: trigger, isAsync } trigger: { op: func, resource: trigger, isAsync },
arrays: (createTx as TxCreateDoc<Trigger>).attributes.arrays === true
}) })
} }
} }
@ -73,47 +78,55 @@ export class Triggers {
async apply ( async apply (
ctx: SessionOperationContext, ctx: SessionOperationContext,
tx: Tx[], tx: Tx[],
ctrl: Omit<TriggerControl, 'txFactory'> ctrl: Omit<TriggerControl, 'txFactory' | 'apply'>
): Promise<{ ): Promise<{
transactions: Tx[] transactions: Tx[]
performAsync?: (ctx: SessionOperationContext) => Promise<Tx[]> performAsync?: (ctx: SessionOperationContext) => Promise<Tx[]>
}> { }> {
const result: Tx[] = [] const result: Tx[] = []
const suppressAsync = (ctx as SessionContextImpl).isAsyncContext ?? false
const asyncRequest: { const asyncRequest: {
matches: Tx[] matches: Tx[]
trigger: TriggerRecord['trigger'] trigger: TriggerRecord['trigger']
arrays: TriggerRecord['arrays']
}[] = [] }[] = []
const applyTrigger = async ( const applyTrigger = async (
ctx: SessionOperationContext, ctx: SessionOperationContext,
matches: Tx[], matches: Tx[],
trigger: TriggerRecord['trigger'], { trigger, arrays }: TriggerRecord,
result: Tx[] result: Tx[]
): Promise<void> => { ): Promise<void> => {
for (const tx of matches) { const group = groupByArray(matches, (it) => it.modifiedBy)
try {
result.push( const tctrl: TriggerControl = {
...(await trigger.op(tx, { ...ctrl,
...ctrl, operationContext: ctx,
ctx: ctx.ctx, ctx: ctx.ctx,
txFactory: new TxFactory(tx.modifiedBy, true), txFactory: new TxFactory(core.account.System, true),
findAll: async (clazz, query, options) => await ctrl.findAllCtx(ctx.ctx, clazz, query, options), findAll: async (clazz, query, options) => await ctrl.findAllCtx(ctx.ctx, clazz, query, options),
apply: async (tx, broadcast, target) => { apply: async (tx, needResult) => {
return await ctrl.applyCtx(ctx, tx, broadcast, target) return await ctrl.applyCtx(ctx, tx, needResult)
}, },
result result
})) }
) for (const [k, v] of group.entries()) {
} catch (err: any) { const m = arrays ? [v] : v
ctx.ctx.error('failed to process trigger', { trigger: trigger.resource, tx, err }) tctrl.txFactory = new TxFactory(k, true)
Analytics.handleError(err) for (const tx of m) {
try {
result.push(...(await trigger.op(tx, tctrl)))
} catch (err: any) {
ctx.ctx.error('failed to process trigger', { trigger: trigger.resource, tx, err })
Analytics.handleError(err)
}
} }
} }
} }
const promises: Promise<void>[] = [] for (const { query, trigger, arrays } of this.triggers) {
for (const { query, trigger } of this.triggers) {
let matches = tx let matches = tx
if (query !== undefined) { if (query !== undefined) {
this.addDerived(query, 'objectClass') this.addDerived(query, 'objectClass')
@ -121,23 +134,26 @@ export class Triggers {
matches = matchQuery(tx, query, core.class.Tx, ctrl.hierarchy) as Tx[] matches = matchQuery(tx, query, core.class.Tx, ctrl.hierarchy) as Tx[]
} }
if (matches.length > 0) { if (matches.length > 0) {
if (trigger.isAsync) { if (trigger.isAsync && !suppressAsync) {
asyncRequest.push({ asyncRequest.push({
matches, matches,
trigger trigger,
arrays
}) })
} else { } else {
promises.push( await ctx.with(
ctx.with(trigger.resource, {}, async (ctx) => { trigger.resource,
await applyTrigger(ctx, matches, trigger, result) {},
}) async (ctx) => {
const tresult: Tx[] = []
await applyTrigger(ctx, matches, { trigger, arrays }, tresult)
result.push(...tresult)
},
{ count: matches.length, arrays }
) )
} }
} }
} }
// Wait all regular triggers to complete in parallel
await Promise.all(promises)
return { return {
transactions: result, transactions: result,
performAsync: performAsync:
@ -148,7 +164,7 @@ export class Triggers {
for (const request of asyncRequest) { for (const request of asyncRequest) {
try { try {
await ctx.with(request.trigger.resource, {}, async (ctx) => { await ctx.with(request.trigger.resource, {}, async (ctx) => {
await applyTrigger(ctx, request.matches, request.trigger, result) await applyTrigger(ctx, request.matches, request, result)
}) })
} catch (err: any) { } catch (err: any) {
ctx.ctx.error('failed to process trigger', { ctx.ctx.error('failed to process trigger', {

View File

@ -16,6 +16,7 @@
import { import {
MeasureMetricsContext, MeasureMetricsContext,
type Account, type Account,
type Branding,
type Class, type Class,
type Doc, type Doc,
type DocumentQuery, type DocumentQuery,
@ -40,8 +41,7 @@ import {
type TxFactory, type TxFactory,
type TxResult, type TxResult,
type WorkspaceId, type WorkspaceId,
type WorkspaceIdWithUrl, type WorkspaceIdWithUrl
type Branding
} from '@hcengineering/core' } from '@hcengineering/core'
import type { Asset, Resource } from '@hcengineering/platform' import type { Asset, Resource } from '@hcengineering/platform'
import { type Readable } from 'stream' import { type Readable } from 'stream'
@ -162,8 +162,8 @@ export interface TriggerControl {
storageAdapter: StorageAdapter storageAdapter: StorageAdapter
serviceAdaptersManager: ServiceAdaptersManager serviceAdaptersManager: ServiceAdaptersManager
// Bulk operations in case trigger require some // Bulk operations in case trigger require some
apply: (tx: Tx[], broadcast: boolean, target?: string[]) => Promise<TxResult> apply: (tx: Tx[], needResult?: boolean) => Promise<TxResult>
applyCtx: (ctx: SessionOperationContext, tx: Tx[], broadcast: boolean, target?: string[]) => Promise<TxResult> applyCtx: (ctx: SessionOperationContext, tx: Tx[], needResult?: boolean) => Promise<TxResult>
// Will create a live query if missing and return values immediately if already asked. // Will create a live query if missing and return values immediately if already asked.
queryFind: <T extends Doc>( queryFind: <T extends Doc>(
@ -179,7 +179,7 @@ export interface TriggerControl {
/** /**
* @public * @public
*/ */
export type TriggerFunc = (tx: Tx, ctrl: TriggerControl) => Promise<Tx[]> export type TriggerFunc = (tx: Tx | Tx[], ctrl: TriggerControl) => Promise<Tx[]>
/** /**
* @public * @public
@ -192,6 +192,9 @@ export interface Trigger extends Doc {
// We should match transaction // We should match transaction
txMatch?: DocumentQuery<Tx> txMatch?: DocumentQuery<Tx>
// If set trigger will handle Tx[] instead of Tx
arrays?: boolean
} }
/** /**

View File

@ -142,7 +142,8 @@ export class SessionContextImpl implements SessionContext {
readonly admin: boolean | undefined, readonly admin: boolean | undefined,
readonly derived: SessionContext['derived'], readonly derived: SessionContext['derived'],
readonly workspace: WorkspaceIdWithUrl, readonly workspace: WorkspaceIdWithUrl,
readonly branding: Branding | null readonly branding: Branding | null,
readonly isAsyncContext: boolean
) {} ) {}
with<T>( with<T>(
@ -163,7 +164,8 @@ export class SessionContextImpl implements SessionContext {
this.admin, this.admin,
this.derived, this.derived,
this.workspace, this.workspace,
this.branding this.branding,
this.isAsyncContext
) )
), ),
fullParams fullParams

View File

@ -32,7 +32,7 @@ import platform, { PlatformError, Severity, Status } from '@hcengineering/platfo
import { Middleware, SessionContext, TxMiddlewareResult, type ServerStorage } from '@hcengineering/server-core' import { Middleware, SessionContext, TxMiddlewareResult, type ServerStorage } from '@hcengineering/server-core'
import { DOMAIN_PREFERENCE } from '@hcengineering/server-preference' import { DOMAIN_PREFERENCE } from '@hcengineering/server-preference'
import { BaseMiddleware } from './base' import { BaseMiddleware } from './base'
import { getUser, mergeTargets } from './utils' import { getUser } from './utils'
/** /**
* @public * @public
@ -65,12 +65,7 @@ export class PrivateMiddleware extends BaseMiddleware implements Middleware {
} }
} }
} }
const res = await this.provideTx(ctx, tx) return await this.provideTx(ctx, tx)
// Add target to all broadcasts
ctx.derived.forEach((it) => {
it.target = mergeTargets(target, it.target)
})
return res
} }
override async findAll<T extends Doc>( override async findAll<T extends Doc>(

View File

@ -335,10 +335,8 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
await this.processPermissionsUpdatesFromTx(ctx, tx) await this.processPermissionsUpdatesFromTx(ctx, tx)
await this.checkPermissions(ctx, tx) await this.checkPermissions(ctx, tx)
const res = await this.provideTx(ctx, tx) const res = await this.provideTx(ctx, tx)
for (const txd of ctx.derived) { for (const txd of ctx.derived.txes) {
for (const tx of txd.derived) { await this.processPermissionsUpdatesFromTx(ctx, txd)
await this.processPermissionsUpdatesFromTx(ctx, tx)
}
} }
return res return res
} }
@ -347,7 +345,9 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
if (tx._class === core.class.TxApplyIf) { if (tx._class === core.class.TxApplyIf) {
const applyTx = tx as TxApplyIf const applyTx = tx as TxApplyIf
await Promise.all(applyTx.txes.map((t) => this.checkPermissions(ctx, t))) for (const t of applyTx.txes) {
await this.checkPermissions(ctx, t)
}
return return
} }

View File

@ -46,7 +46,7 @@ import core, {
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
import { Middleware, SessionContext, TxMiddlewareResult, type ServerStorage } from '@hcengineering/server-core' import { Middleware, SessionContext, TxMiddlewareResult, type ServerStorage } from '@hcengineering/server-core'
import { BaseMiddleware } from './base' import { BaseMiddleware } from './base'
import { getUser, isOwner, isSystem, mergeTargets } from './utils' import { getUser, isOwner, isSystem } from './utils'
type SpaceWithMembers = Pick<Space, '_id' | 'members' | 'private' | '_class'> type SpaceWithMembers = Pick<Space, '_id' | 'members' | 'private' | '_class'>
@ -246,10 +246,13 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar
space: core.space.DerivedTx, space: core.space.DerivedTx,
params: null params: null
} }
ctx.derived.push({ ctx.derived.txes.push(tx)
derived: [tx], ctx.derived.targets.security = (it) => {
target: targets // TODO: I'm not sure it is called
}) if (it._id === tx._id) {
return targets
}
}
} }
private async broadcastNonMembers (ctx: SessionContext, space: SpaceWithMembers): Promise<void> { private async broadcastNonMembers (ctx: SessionContext, space: SpaceWithMembers): Promise<void> {
@ -413,17 +416,10 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
} }
await this.processTx(ctx, tx) await this.processTx(ctx, tx)
const targets = await this.getTxTargets(ctx, tx)
const res = await this.provideTx(ctx, tx) const res = await this.provideTx(ctx, tx)
for (const txd of ctx.derived) { for (const txd of ctx.derived.txes) {
for (const tx of txd.derived) { await this.processTx(ctx, txd)
await this.processTx(ctx, tx)
}
} }
ctx.derived.forEach((it) => {
it.target = mergeTargets(targets, it.target)
})
return res return res
} }
@ -435,6 +431,7 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar
for (const tx of txes) { for (const tx of txes) {
const h = this.storage.hierarchy const h = this.storage.hierarchy
if (h.isDerived(tx._class, core.class.TxCUD)) { if (h.isDerived(tx._class, core.class.TxCUD)) {
// TODO: Do we need security check here?
const cudTx = tx as TxCUD<Doc> const cudTx = tx as TxCUD<Doc>
await this.processTxSpaceDomain(cudTx) await this.processTxSpaceDomain(cudTx)
} else if (tx._class === core.class.TxWorkspaceEvent) { } else if (tx._class === core.class.TxWorkspaceEvent) {

View File

@ -17,18 +17,6 @@ import core, { Account, AccountRole, systemAccountEmail } from '@hcengineering/c
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
import { SessionContext, type ServerStorage } from '@hcengineering/server-core' import { SessionContext, type ServerStorage } from '@hcengineering/server-core'
export function mergeTargets (current: string[] | undefined, prev: string[] | undefined): string[] | undefined {
if (current === undefined) return prev
if (prev === undefined) return current
const res: string[] = []
for (const value of current) {
if (prev.includes(value)) {
res.push(value)
}
}
return res
}
export async function getUser (storage: ServerStorage, ctx: SessionContext): Promise<Account> { export async function getUser (storage: ServerStorage, ctx: SessionContext): Promise<Account> {
if (ctx.userEmail === undefined) { if (ctx.userEmail === undefined) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))

View File

@ -182,7 +182,10 @@ describe('mongo operations', () => {
}) })
const soCtx: SessionOperationContext = { const soCtx: SessionOperationContext = {
ctx, ctx,
derived: [], derived: {
txes: [],
targets: {}
},
with: async <T>( with: async <T>(
name: string, name: string,
params: ParamsType, params: ParamsType,
@ -206,7 +209,6 @@ describe('mongo operations', () => {
clean: async (domain: Domain, docs: Ref<Doc>[]) => {}, clean: async (domain: Domain, docs: Ref<Doc>[]) => {},
loadModel: async () => txes, loadModel: async () => txes,
getAccount: async () => ({}) as any, getAccount: async () => ({}) as any,
measure: async () => async () => ({ time: 0, serverTime: 0 }),
sendForceClose: async () => {} sendForceClose: async () => {}
} }
return st return st

View File

@ -114,9 +114,13 @@ export class ClientSession implements Session {
this.token.email, this.token.email,
this.sessionId, this.sessionId,
this.token.extra?.admin === 'true', this.token.extra?.admin === 'true',
[], {
txes: [],
targets: {}
},
this._pipeline.storage.workspaceId, this._pipeline.storage.workspaceId,
this._pipeline.storage.branding this._pipeline.storage.branding,
false
) )
await this._pipeline.tx(context, createTx) await this._pipeline.tx(context, createTx)
const acc = TxProcessor.createDoc2Doc(createTx) const acc = TxProcessor.createDoc2Doc(createTx)
@ -144,9 +148,13 @@ export class ClientSession implements Session {
this.token.email, this.token.email,
this.sessionId, this.sessionId,
this.token.extra?.admin === 'true', this.token.extra?.admin === 'true',
[], {
txes: [],
targets: {}
},
this._pipeline.storage.workspaceId, this._pipeline.storage.workspaceId,
this._pipeline.storage.branding this._pipeline.storage.branding,
false
) )
return await this._pipeline.findAll(context, _class, query, options) return await this._pipeline.findAll(context, _class, query, options)
} }
@ -167,9 +175,13 @@ export class ClientSession implements Session {
this.token.email, this.token.email,
this.sessionId, this.sessionId,
this.token.extra?.admin === 'true', this.token.extra?.admin === 'true',
[], {
txes: [],
targets: {}
},
this._pipeline.storage.workspaceId, this._pipeline.storage.workspaceId,
this._pipeline.storage.branding this._pipeline.storage.branding,
false
) )
await ctx.sendResponse(await this._pipeline.searchFulltext(context, query, options)) await ctx.sendResponse(await this._pipeline.searchFulltext(context, query, options))
} }
@ -183,9 +195,13 @@ export class ClientSession implements Session {
this.token.email, this.token.email,
this.sessionId, this.sessionId,
this.token.extra?.admin === 'true', this.token.extra?.admin === 'true',
[], {
txes: [],
targets: {}
},
this._pipeline.storage.workspaceId, this._pipeline.storage.workspaceId,
this._pipeline.storage.branding this._pipeline.storage.branding,
false
) )
const result = await this._pipeline.tx(context, tx) const result = await this._pipeline.tx(context, tx)
@ -209,18 +225,24 @@ export class ClientSession implements Session {
} }
// Put current user as send target // Put current user as send target
toSendTarget.set(this.getUser(), []) for (const txd of context.derived.txes) {
for (const txd of context.derived) { let target: string[] | undefined
if (txd.target === undefined) { for (const tt of Object.values(context.derived.targets ?? {})) {
getTxes('') // be sure we have empty one target = tt(txd)
if (target !== undefined) {
break
}
}
if (target === undefined) {
getTxes('') // Be sure we have empty one
// Also add to all other targeted sends // Also add to all other targeted sends
for (const v of toSendTarget.values()) { for (const v of toSendTarget.values()) {
v.push(...txd.derived) v.push(txd)
} }
} else { } else {
for (const t of txd.target) { for (const t of target) {
getTxes(t).push(...txd.derived) getTxes(t).push(txd)
} }
} }
} }

View File

@ -912,10 +912,6 @@ class TSessionManager implements SessionManager {
} }
}) })
if (request.method === 'measure' || request.method === 'measure-done') {
await this.handleMeasure<S>(service, request, opContext(ctx))
return
}
service.requests.set(reqId, { service.requests.set(reqId, {
id: reqId, id: reqId,
params: request, params: request,
@ -930,9 +926,7 @@ class TSessionManager implements SessionManager {
try { try {
const params = [...request.params] const params = [...request.params]
service.measureCtx?.ctx !== undefined await ctx.with('🧨 process', {}, async (callTx) => f.apply(service, [opContext(callTx), ...params]))
? await f.apply(service, [opContext(service.measureCtx?.ctx), ...params])
: await ctx.with('🧨 process', {}, async (callTx) => f.apply(service, [opContext(callTx), ...params]))
} catch (err: any) { } catch (err: any) {
Analytics.handleError(err) Analytics.handleError(err)
if (LOGGING_ENABLED) { if (LOGGING_ENABLED) {
@ -955,34 +949,7 @@ class TSessionManager implements SessionManager {
service.requests.delete(reqId) service.requests.delete(reqId)
} }
} }
private async handleMeasure<S extends Session>(
service: S,
request: Request<any[]>,
ctx: ClientSessionCtx
): Promise<void> {
let serverTime = 0
if (request.method === 'measure') {
service.measureCtx = { ctx: ctx.ctx.newChild('📶 ' + request.params[0], {}), time: Date.now() }
} else {
if (service.measureCtx !== undefined) {
serverTime = Date.now() - service.measureCtx.time
service.measureCtx.ctx.end(serverTime)
service.measureCtx = undefined
}
}
try {
await ctx.sendResponse(request.method === 'measure' ? 'started' : serverTime)
} catch (err: any) {
Analytics.handleError(err)
if (LOGGING_ENABLED) {
ctx.ctx.error('error handle measure', { error: err, request })
}
await ctx.sendError(JSON.parse(JSON.stringify(err?.stack)), unknownError(err))
}
}
} }
/** /**
* @public * @public
*/ */

View File

@ -77,8 +77,6 @@ export interface Session {
current: StatisticsElement current: StatisticsElement
mins5: StatisticsElement mins5: StatisticsElement
measureCtx?: { ctx: MeasureContext, time: number }
lastRequest: number lastRequest: number
isUpgradeClient: () => boolean isUpgradeClient: () => boolean