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-view": "^0.6.0",
"@hcengineering/model-workbench": "^0.6.1",
"@hcengineering/model-presentation": "^0.6.0",
"@hcengineering/notification": "^0.6.23",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/ui": "^0.6.15",

View File

@ -24,6 +24,7 @@ import {
type ObjectChatPanel,
type ThreadMessage
} from '@hcengineering/chunter'
import presentation from '@hcengineering/model-presentation'
import contact from '@hcengineering/contact'
import {
type Class,
@ -457,6 +458,10 @@ export function createModel (builder: Builder): void {
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, {
presenter: chunter.component.ThreadMessagePresenter
})

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import {
type Tx,
type Blob,
type Class,
type Client,
@ -183,3 +184,10 @@ export interface FilePreviewExtension extends ComponentPointExtension {
// Extension is only available if this checker returns true
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 core, {
TxOperations,
TxProcessor,
concatLink,
getCurrentAccount,
reduceCalls,
type TxApplyIf,
type AnyAttribute,
type ArrOf,
type AttachedDoc,
@ -46,7 +48,8 @@ import core, {
type Tx,
type TxResult,
type TypeAny,
type WithLookup
type WithLookup,
type TxCUD
} from '@hcengineering/core'
import { getMetadata, getResource } from '@hcengineering/platform'
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 { deepEqual } from 'fast-equals'
import { onDestroy } from 'svelte'
import { get } from 'svelte/store'
import { type Writable, get, writable } from 'svelte/store'
import { type KeyedAttribute } from '..'
import { OptimizeQueryMiddleware, PresentationPipelineImpl, type PresentationPipeline } from './pipeline'
import plugin from './plugin'
export { reduceCalls } from '@hcengineering/core'
let liveQuery: LQ
let client: TxOperations & MeasureClient
let client: TxOperations & MeasureClient & OptimisticTxes
let pipeline: PresentationPipeline
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 (
client: MeasureClient,
private readonly liveQuery: Client
@ -93,23 +100,56 @@ class UIClient extends TxOperations implements Client, MeasureClient {
afterMeasure: Tx[] = []
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> {
if (this.measureOp !== undefined) {
this.afterMeasure.push(...tx)
} else {
try {
await pipeline.notifyTx(...tx)
const pending = get(this._pendingCreatedDocs)
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) => {
it(...tx)
})
} catch (err: any) {
Analytics.handleError(err)
console.log(err)
if (innerTx._class === core.class.TxCreateDoc) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete pending[innerTx.objectId]
pendingUpdated = true
}
}
})
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> {
void this.notifyEarly(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> {
return await this.client.searchFulltext(query, options)
}
@ -159,7 +239,7 @@ class UIClient extends TxOperations implements Client, MeasureClient {
/**
* @public
*/
export function getClient (): TxOperations & MeasureClient {
export function getClient (): TxOperations & MeasureClient & OptimisticTxes {
return client
}

View File

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

View File

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

View File

@ -54,8 +54,9 @@
export let onClick: (() => void) | undefined = undefined
const client = getClient()
const { pendingCreatedDocs } = client
const hierarchy = client.getHierarchy()
const STALE_TIMEOUT_MS = 5000
const userQuery = createQuery()
const currentAccount = getCurrentAccount()
@ -103,6 +104,21 @@
$: 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[] {
if (!content) {
return []
@ -185,6 +201,8 @@
{hoverable}
{hoverStyles}
{skipLabel}
{pending}
{stale}
showDatePreposition={hideLink}
{type}
{onClick}