uberf-7389: instant transactions (#5941)

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
This commit is contained in:
Alexey Zinoviev 2024-06-28 19:21:54 +04:00 committed by GitHub
parent 51383caa92
commit 6af082e66f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 164 additions and 29 deletions

View File

@ -41,6 +41,7 @@
"@hcengineering/model-notification": "^0.6.0", "@hcengineering/model-notification": "^0.6.0",
"@hcengineering/model-view": "^0.6.0", "@hcengineering/model-view": "^0.6.0",
"@hcengineering/model-workbench": "^0.6.1", "@hcengineering/model-workbench": "^0.6.1",
"@hcengineering/model-presentation": "^0.6.0",
"@hcengineering/notification": "^0.6.23", "@hcengineering/notification": "^0.6.23",
"@hcengineering/platform": "^0.6.11", "@hcengineering/platform": "^0.6.11",
"@hcengineering/ui": "^0.6.15", "@hcengineering/ui": "^0.6.15",

View File

@ -24,6 +24,7 @@ import {
type ObjectChatPanel, type ObjectChatPanel,
type ThreadMessage type ThreadMessage
} from '@hcengineering/chunter' } from '@hcengineering/chunter'
import presentation from '@hcengineering/model-presentation'
import contact from '@hcengineering/contact' import contact from '@hcengineering/contact'
import { import {
type Class, type Class,
@ -457,6 +458,10 @@ export function createModel (builder: Builder): void {
presenter: chunter.component.ChatMessagePresenter presenter: chunter.component.ChatMessagePresenter
}) })
builder.mixin(chunter.class.ChatMessage, core.class.Class, presentation.mixin.InstantTransactions, {
txClasses: [core.class.TxCreateDoc]
})
builder.mixin(chunter.class.ThreadMessage, core.class.Class, view.mixin.ObjectPresenter, { builder.mixin(chunter.class.ThreadMessage, core.class.Class, view.mixin.ObjectPresenter, {
presenter: chunter.component.ThreadMessagePresenter presenter: chunter.component.ThreadMessagePresenter
}) })

View File

@ -13,9 +13,9 @@
// limitations under the License. // limitations under the License.
// //
import { DOMAIN_MODEL, type Blob, type Class, type Doc, type Ref } from '@hcengineering/core' import { DOMAIN_MODEL, type Tx, type Blob, type Class, type Doc, type Ref } from '@hcengineering/core'
import { Model, Prop, TypeRef, TypeString, type Builder } from '@hcengineering/model' import { Mixin, Model, Prop, TypeRef, TypeString, type Builder } from '@hcengineering/model'
import core, { TDoc } from '@hcengineering/model-core' import core, { TClass, TDoc } from '@hcengineering/model-core'
import { type Asset, type IntlString, type Resource } from '@hcengineering/platform' import { type Asset, type IntlString, type Resource } from '@hcengineering/platform'
// Import types to prevent .svelte components to being exposed to type typescript. // Import types to prevent .svelte components to being exposed to type typescript.
import { import {
@ -33,7 +33,8 @@ import {
type FilePreviewExtension, type FilePreviewExtension,
type ObjectSearchCategory, type ObjectSearchCategory,
type ObjectSearchContext, type ObjectSearchContext,
type ObjectSearchFactory type ObjectSearchFactory,
type InstantTransactions
} from '@hcengineering/presentation/src/types' } from '@hcengineering/presentation/src/types'
import { type AnyComponent, type ComponentExtensionId } from '@hcengineering/ui/src/types' import { type AnyComponent, type ComponentExtensionId } from '@hcengineering/ui/src/types'
import presentation from './plugin' import presentation from './plugin'
@ -94,6 +95,11 @@ export class TFilePreviewExtension extends TComponentPointExtension implements F
availabilityChecker?: Resource<() => Promise<boolean>> availabilityChecker?: Resource<() => Promise<boolean>>
} }
@Mixin(presentation.mixin.InstantTransactions, core.class.Class)
export class TInstantTransactions extends TClass implements InstantTransactions {
txClasses!: Array<Ref<Class<Tx>>>
}
export function createModel (builder: Builder): void { export function createModel (builder: Builder): void {
builder.createModel( builder.createModel(
TObjectSearchCategory, TObjectSearchCategory,
@ -101,6 +107,7 @@ export function createModel (builder: Builder): void {
TComponentPointExtension, TComponentPointExtension,
TDocCreateExtension, TDocCreateExtension,
TDocRules, TDocRules,
TFilePreviewExtension TFilePreviewExtension,
TInstantTransactions
) )
} }

View File

@ -14,7 +14,7 @@
// limitations under the License. // limitations under the License.
// //
import { type Class, type Ref } from '@hcengineering/core' import { type Mixin, type Class, type Ref } 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'
@ -24,7 +24,8 @@ import {
type DocRules, type DocRules,
type DocCreateExtension, type DocCreateExtension,
type FilePreviewExtension, type FilePreviewExtension,
type ObjectSearchCategory type ObjectSearchCategory,
type InstantTransactions
} from './types' } from './types'
import type { PreviewConfig } from './preview' import type { PreviewConfig } from './preview'
@ -42,6 +43,9 @@ export default plugin(presentationId, {
DocRules: '' as Ref<Class<DocRules>>, DocRules: '' as Ref<Class<DocRules>>,
FilePreviewExtension: '' as Ref<Class<FilePreviewExtension>> FilePreviewExtension: '' as Ref<Class<FilePreviewExtension>>
}, },
mixin: {
InstantTransactions: '' as Ref<Mixin<InstantTransactions>>
},
string: { string: {
Create: '' as IntlString, Create: '' as IntlString,
Cancel: '' as IntlString, Cancel: '' as IntlString,

View File

@ -1,4 +1,5 @@
import { import {
type Tx,
type Blob, type Blob,
type Class, type Class,
type Client, type Client,
@ -183,3 +184,10 @@ export interface FilePreviewExtension extends ComponentPointExtension {
// Extension is only available if this checker returns true // Extension is only available if this checker returns true
availabilityChecker?: Resource<() => Promise<boolean>> availabilityChecker?: Resource<() => Promise<boolean>>
} }
/**
* @public
*/
export interface InstantTransactions extends Class<Doc> {
txClasses: Array<Ref<Class<Tx>>>
}

View File

@ -17,9 +17,11 @@
import { Analytics } from '@hcengineering/analytics' import { Analytics } from '@hcengineering/analytics'
import core, { import core, {
TxOperations, TxOperations,
TxProcessor,
concatLink, concatLink,
getCurrentAccount, getCurrentAccount,
reduceCalls, reduceCalls,
type TxApplyIf,
type AnyAttribute, type AnyAttribute,
type ArrOf, type ArrOf,
type AttachedDoc, type AttachedDoc,
@ -46,7 +48,8 @@ import core, {
type Tx, type Tx,
type TxResult, type TxResult,
type TypeAny, type TypeAny,
type WithLookup type WithLookup,
type TxCUD
} from '@hcengineering/core' } from '@hcengineering/core'
import { getMetadata, getResource } from '@hcengineering/platform' import { getMetadata, getResource } from '@hcengineering/platform'
import { LiveQuery as LQ } from '@hcengineering/query' import { LiveQuery as LQ } from '@hcengineering/query'
@ -54,14 +57,14 @@ import { getRawCurrentLocation, workspaceId, type AnyComponent, type AnySvelteCo
import view, { type AttributeCategory, type AttributeEditor } from '@hcengineering/view' import view, { type AttributeCategory, type AttributeEditor } from '@hcengineering/view'
import { deepEqual } from 'fast-equals' import { deepEqual } from 'fast-equals'
import { onDestroy } from 'svelte' import { onDestroy } from 'svelte'
import { get } from 'svelte/store' import { type Writable, get, writable } from 'svelte/store'
import { type KeyedAttribute } from '..' import { type KeyedAttribute } from '..'
import { OptimizeQueryMiddleware, PresentationPipelineImpl, type PresentationPipeline } from './pipeline' import { OptimizeQueryMiddleware, PresentationPipelineImpl, type PresentationPipeline } from './pipeline'
import plugin from './plugin' import plugin from './plugin'
export { reduceCalls } from '@hcengineering/core' export { reduceCalls } from '@hcengineering/core'
let liveQuery: LQ let liveQuery: LQ
let client: TxOperations & MeasureClient let client: TxOperations & MeasureClient & OptimisticTxes
let pipeline: PresentationPipeline let pipeline: PresentationPipeline
const txListeners: Array<(...tx: Tx[]) => void> = [] const txListeners: Array<(...tx: Tx[]) => void> = []
@ -83,7 +86,11 @@ export function removeTxListener (l: (tx: Tx) => void): void {
} }
} }
class UIClient extends TxOperations implements Client, MeasureClient { export interface OptimisticTxes {
pendingCreatedDocs: Writable<Record<Ref<Doc>, boolean>>
}
class UIClient extends TxOperations implements Client, MeasureClient, OptimisticTxes {
constructor ( constructor (
client: MeasureClient, client: MeasureClient,
private readonly liveQuery: Client private readonly liveQuery: Client
@ -93,23 +100,56 @@ class UIClient extends TxOperations implements Client, MeasureClient {
afterMeasure: Tx[] = [] afterMeasure: Tx[] = []
measureOp?: MeasureDoneOperation measureOp?: MeasureDoneOperation
protected pendingTxes = new Set<Ref<Tx>>()
protected _pendingCreatedDocs = writable<Record<Ref<Doc>, boolean>>({})
get pendingCreatedDocs (): typeof this._pendingCreatedDocs {
return this._pendingCreatedDocs
}
async doNotify (...tx: Tx[]): Promise<void> { async doNotify (...tx: Tx[]): Promise<void> {
if (this.measureOp !== undefined) { if (this.measureOp !== undefined) {
this.afterMeasure.push(...tx) this.afterMeasure.push(...tx)
} else { } else {
try { const pending = get(this._pendingCreatedDocs)
await pipeline.notifyTx(...tx) let pendingUpdated = false
tx.forEach((t) => {
if (this.pendingTxes.has(t._id)) {
this.pendingTxes.delete(t._id)
await liveQuery.tx(...tx) // Only CUD tx can be pending now
const innerTx = TxProcessor.extractTx(t) as TxCUD<Doc>
txListeners.forEach((it) => { if (innerTx._class === core.class.TxCreateDoc) {
it(...tx) // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
}) delete pending[innerTx.objectId]
} catch (err: any) { pendingUpdated = true
Analytics.handleError(err) }
console.log(err) }
})
if (pendingUpdated) {
this._pendingCreatedDocs.set(pending)
} }
// 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> {
try {
await pipeline.notifyTx(...tx)
await liveQuery.tx(...tx)
txListeners.forEach((it) => {
it(...tx)
})
} catch (err: any) {
Analytics.handleError(err)
console.log(err)
} }
} }
@ -130,9 +170,49 @@ class UIClient extends TxOperations implements Client, MeasureClient {
} }
override async tx (tx: Tx): Promise<TxResult> { override async tx (tx: Tx): Promise<TxResult> {
void this.notifyEarly(tx)
return await this.client.tx(tx) return await this.client.tx(tx)
} }
private async notifyEarly (tx: Tx): Promise<void> {
if (tx._class === core.class.TxApplyIf) {
const applyTx = tx as TxApplyIf
if (applyTx.match.length !== 0 || applyTx.notMatch.length !== 0) {
// Cannot early apply conditional transactions
return
}
await Promise.all(
applyTx.txes.map(async (atx) => {
await this.notifyEarly(atx)
})
)
return
}
if (!this.getHierarchy().isDerived(tx._class, core.class.TxCUD)) {
return
}
const innerTx = TxProcessor.extractTx(tx) as TxCUD<Doc>
// Can pre-build some configuration later from the model if this will be too slow.
const instantTxes = this.getHierarchy().classHierarchyMixin(innerTx.objectClass, plugin.mixin.InstantTransactions)
if (instantTxes?.txClasses.includes(innerTx._class) !== true) {
return
}
if (innerTx._class === core.class.TxCreateDoc) {
const pending = get(this._pendingCreatedDocs)
pending[innerTx.objectId] = true
this._pendingCreatedDocs.set(pending)
}
this.pendingTxes.add(tx._id)
await this.provideNotify(tx)
}
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> { async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
return await this.client.searchFulltext(query, options) return await this.client.searchFulltext(query, options)
} }
@ -159,7 +239,7 @@ class UIClient extends TxOperations implements Client, MeasureClient {
/** /**
* @public * @public
*/ */
export function getClient (): TxOperations & MeasureClient { export function getClient (): TxOperations & MeasureClient & OptimisticTxes {
return client return client
} }

View File

@ -49,6 +49,8 @@
export let hideFooter = false export let hideFooter = false
export let skipLabel = false export let skipLabel = false
export let hoverable = true export let hoverable = true
export let pending = false
export let stale = false
export let hoverStyles: 'borderedHover' | 'filledHover' = 'borderedHover' export let hoverStyles: 'borderedHover' | 'filledHover' = 'borderedHover'
export let showDatePreposition = false export let showDatePreposition = false
export let type: ActivityMessageViewType = 'default' export let type: ActivityMessageViewType = 'default'
@ -158,6 +160,7 @@
class:actionsOpened={isActionsOpened} class:actionsOpened={isActionsOpened}
class:borderedHover={hoverStyles === 'borderedHover'} class:borderedHover={hoverStyles === 'borderedHover'}
class:filledHover={hoverStyles === 'filledHover'} class:filledHover={hoverStyles === 'filledHover'}
class:stale
on:click={onClick} on:click={onClick}
on:contextmenu={handleContextMenu} on:contextmenu={handleContextMenu}
> >
@ -226,7 +229,7 @@
</div> </div>
{#if withActions && !readonly} {#if withActions && !readonly}
<div class="actions" class:opened={isActionsOpened}> <div class="actions" class:pending class:opened={isActionsOpened}>
<ActivityMessageActions <ActivityMessageActions
message={isReactionMessage(message) ? parentMessage : message} message={isReactionMessage(message) ? parentMessage : message}
{actions} {actions}
@ -282,13 +285,15 @@
top: -0.75rem; top: -0.75rem;
right: 0.75rem; right: 0.75rem;
&.opened { &.opened:not(.pending) {
visibility: visible; visibility: visible;
} }
} }
&:hover > .actions { &:hover > .actions {
visibility: visible; &:not(.pending) {
visibility: visible;
}
} }
&:hover > .time { &:hover > .time {
@ -324,6 +329,10 @@
} }
} }
} }
&.stale {
opacity: 0.5;
}
} }
.header { .header {

View File

@ -100,8 +100,11 @@
} }
async function onMessage (event: CustomEvent) { async function onMessage (event: CustomEvent) {
loading = true if (chatMessage) {
const doneOp = await getClient().measure(`chunter.create.${_class} ${object._class}`) loading = true
} // for new messages we use instant txes
const doneOp = getClient().measure(`chunter.create.${_class} ${object._class}`)
try { try {
draftController.remove() draftController.remove()
inputRef.removeDraft(false) inputRef.removeDraft(false)
@ -116,11 +119,11 @@
currentMessage = getDefault() currentMessage = getDefault()
_id = currentMessage._id _id = currentMessage._id
const d1 = Date.now() const d1 = Date.now()
void doneOp().then((res) => { void (await doneOp)().then((res) => {
console.log(`create.${_class} measure`, res, Date.now() - d1) console.log(`create.${_class} measure`, res, Date.now() - d1)
}) })
} catch (err: any) { } catch (err: any) {
void doneOp() void (await doneOp)()
Analytics.handleError(err) Analytics.handleError(err)
console.error(err) console.error(err)
} }

View File

@ -54,8 +54,9 @@
export let onClick: (() => void) | undefined = undefined export let onClick: (() => void) | undefined = undefined
const client = getClient() const client = getClient()
const { pendingCreatedDocs } = client
const hierarchy = client.getHierarchy() const hierarchy = client.getHierarchy()
const STALE_TIMEOUT_MS = 5000
const userQuery = createQuery() const userQuery = createQuery()
const currentAccount = getCurrentAccount() const currentAccount = getCurrentAccount()
@ -103,6 +104,21 @@
$: links = showLinksPreview ? getLinks(value?.message) : [] $: links = showLinksPreview ? getLinks(value?.message) : []
let stale = false
let markStaleId: NodeJS.Timeout | undefined
$: pending = value?._id !== undefined && $pendingCreatedDocs[value._id]
$: if (pending) {
markStaleId = setTimeout(() => {
stale = true
}, STALE_TIMEOUT_MS)
} else {
if (markStaleId !== undefined) {
clearTimeout(markStaleId)
markStaleId = undefined
}
stale = false
}
function getLinks (content?: string): HTMLLinkElement[] { function getLinks (content?: string): HTMLLinkElement[] {
if (!content) { if (!content) {
return [] return []
@ -185,6 +201,8 @@
{hoverable} {hoverable}
{hoverStyles} {hoverStyles}
{skipLabel} {skipLabel}
{pending}
{stale}
showDatePreposition={hideLink} showDatePreposition={hideLink}
{type} {type}
{onClick} {onClick}