diff --git a/meteor/server/api/deviceTriggers/StudioDeviceTriggerManager.ts b/meteor/server/api/deviceTriggers/StudioDeviceTriggerManager.ts index f710070aba..03efd5c753 100644 --- a/meteor/server/api/deviceTriggers/StudioDeviceTriggerManager.ts +++ b/meteor/server/api/deviceTriggers/StudioDeviceTriggerManager.ts @@ -43,7 +43,7 @@ export class StudioDeviceTriggerManager { StudioActionManagers.set(studioId, new StudioActionManager()) } - updateTriggers(cache: ContentCache, showStyleBaseId: ShowStyleBaseId): void { + async updateTriggers(cache: ContentCache, showStyleBaseId: ShowStyleBaseId): Promise { const studioId = this.studioId this.#lastShowStyleBaseId = showStyleBaseId @@ -88,7 +88,7 @@ export class StudioDeviceTriggerManager { const addedPreviewIds: PreviewWrappedAdLibId[] = [] - Object.entries(triggeredAction.actions).forEach(([key, action]) => { + for (const [key, action] of Object.entries(triggeredAction.actions)) { // Since the compiled action is cached using this actionId as a key, having the action // and the filterChain allows for a quicker invalidation without doing a deepEquals const actionId = protectString( @@ -106,9 +106,9 @@ export class StudioDeviceTriggerManager { } touchedActionIds.push(actionId) - Object.entries(triggeredAction.triggers).forEach(([key, trigger]) => { + for (const [key, trigger] of Object.entries(triggeredAction.triggers)) { if (!isDeviceTrigger(trigger)) { - return + continue } let deviceActionArguments: ShiftRegisterActionArguments | undefined = undefined @@ -141,7 +141,7 @@ export class StudioDeviceTriggerManager { }, }) upsertedDeviceTriggerMountedActionIds.push(deviceTriggerMountedActionId) - }) + } if (!isPreviewableAction(thisAction)) { const adLibPreviewId = protectString(`${actionId}_preview`) @@ -165,7 +165,7 @@ export class StudioDeviceTriggerManager { addedPreviewIds.push(adLibPreviewId) } else { - const previewedAdLibs = thisAction.preview(context) + const previewedAdLibs = await thisAction.preview(context, null) previewedAdLibs.forEach((adLib) => { const adLibPreviewId = protectString( @@ -195,7 +195,7 @@ export class StudioDeviceTriggerManager { addedPreviewIds.push(adLibPreviewId) }) } - }) + } DeviceTriggerMountedActionAdlibsPreview.remove({ triggeredActionId: triggeredAction._id, diff --git a/meteor/server/api/deviceTriggers/observer.ts b/meteor/server/api/deviceTriggers/observer.ts index 30e7a0f42f..fb1448f24e 100644 --- a/meteor/server/api/deviceTriggers/observer.ts +++ b/meteor/server/api/deviceTriggers/observer.ts @@ -44,7 +44,7 @@ MeteorStartupAsync(async () => { const manager = new StudioDeviceTriggerManager(studioId) const observer = new StudioObserver(studioId, (showStyleBaseId, cache) => { workInQueue(async () => { - manager.updateTriggers(cache, showStyleBaseId) + await manager.updateTriggers(cache, showStyleBaseId) }) return () => { @@ -117,10 +117,12 @@ export async function receiveInputDeviceTrigger( if (!actionManager) throw new Meteor.Error(500, `No Studio Action Manager available to handle trigger in Studio "${studioId}"`) - DeviceTriggerMountedActions.find({ + const mountedActions = DeviceTriggerMountedActions.find({ deviceId, deviceTriggerId: triggerId, - }).forEach((mountedAction) => { + }).fetch() + + for (const mountedAction of mountedActions) { if (values && !_.isMatch(values, mountedAction.values)) return const executableAction = actionManager.getAction(mountedAction.actionId) if (!executableAction) @@ -132,6 +134,6 @@ export async function receiveInputDeviceTrigger( const context = actionManager.getContext() if (!context) throw new Meteor.Error(500, `Undefined Device Trigger context for studio "${studioId}"`) - executableAction.execute((t: ITranslatableMessage) => t.key ?? t, `${deviceId}: ${triggerId}`, context) - }) + await executableAction.execute((t: ITranslatableMessage) => t.key ?? t, `${deviceId}: ${triggerId}`, context) + } } diff --git a/meteor/server/api/deviceTriggers/triggersContext.ts b/meteor/server/api/deviceTriggers/triggersContext.ts index c8c95db6b5..ec563c562b 100644 --- a/meteor/server/api/deviceTriggers/triggersContext.ts +++ b/meteor/server/api/deviceTriggers/triggersContext.ts @@ -1,51 +1,68 @@ -import { TriggersContext } from '@sofie-automation/meteor-lib/dist/triggers/triggersContext' +import { + TriggersAsyncCollection, + TriggersContext, + TriggerTrackerComputation, +} from '@sofie-automation/meteor-lib/dist/triggers/triggersContext' import { SINGLE_USE_TOKEN_SALT } from '@sofie-automation/meteor-lib/dist/api/userActions' -import { assertNever, getHash, Time } from '../../lib/tempLib' +import { assertNever, getHash, ProtectedString, Time } from '../../lib/tempLib' import { getCurrentTime } from '../../lib/lib' import { MeteorCall } from '../methods' import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client' import { UserAction } from '@sofie-automation/meteor-lib/dist/userAction' import { TFunction } from 'i18next' -import { Tracker } from 'meteor/tracker' - import { logger } from '../../logging' import { IBaseFilterLink, IRundownPlaylistFilterLink } from '@sofie-automation/blueprints-integration' import { PartId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DummyReactiveVar } from '@sofie-automation/meteor-lib/dist/triggers/reactive-var' import { ReactivePlaylistActionContext } from '@sofie-automation/meteor-lib/dist/triggers/actionFactory' -import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' -import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' -import { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' -import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' -import { PartInstance } from '@sofie-automation/meteor-lib/dist/collections/PartInstances' +import { FindOneOptions, FindOptions, MongoQuery } from '@sofie-automation/corelib/dist/mongo' +import { DBRundownPlaylist, SelectedPartInstance } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { + AdLibActions, + AdLibPieces, + PartInstances, + Parts, + RundownBaselineAdLibActions, + RundownBaselineAdLibPieces, + RundownPlaylists, + Rundowns, + Segments, +} from '../../collections' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' -import { RundownBaselineAdLibAction } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibAction' -import { RundownBaselineAdLibItem } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibPiece' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { createSyncReadOnlyMongoCollection } from './triggersContextCollection' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { AsyncOnlyReadOnlyMongoCollection } from '../../collections/collection' export function hashSingleUseToken(token: string): string { return getHash(SINGLE_USE_TOKEN_SALT + token) } -/** - * Some synchronous read-only collections to satisfy the TriggersContext interface - */ -const AdLibActions = createSyncReadOnlyMongoCollection(CollectionName.AdLibActions) -const AdLibPieces = createSyncReadOnlyMongoCollection(CollectionName.AdLibPieces) -const PartInstances = createSyncReadOnlyMongoCollection(CollectionName.PartInstances) -const Parts = createSyncReadOnlyMongoCollection(CollectionName.Parts) -const RundownBaselineAdLibActions = createSyncReadOnlyMongoCollection( - CollectionName.RundownBaselineAdLibActions -) -const RundownBaselineAdLibPieces = createSyncReadOnlyMongoCollection( - CollectionName.RundownBaselineAdLibPieces -) -const RundownPlaylists = createSyncReadOnlyMongoCollection(CollectionName.RundownPlaylists) -const Rundowns = createSyncReadOnlyMongoCollection(CollectionName.Rundowns) -const Segments = createSyncReadOnlyMongoCollection(CollectionName.Segments) +class MeteorTriggersCollectionWrapper }> + implements TriggersAsyncCollection +{ + readonly #collection: AsyncOnlyReadOnlyMongoCollection + + constructor(collection: AsyncOnlyReadOnlyMongoCollection) { + this.#collection = collection + } + + async findFetchAsync( + _computation: TriggerTrackerComputation | null, + selector: MongoQuery, + options?: FindOptions + ): Promise> { + // Note: the _computation is not used, since we are not using Tracker server-side + return this.#collection.findFetchAsync(selector, options) + } + + async findOneAsync( + _computation: TriggerTrackerComputation | null, + selector: MongoQuery | DBInterface['_id'], + options?: FindOneOptions + ): Promise { + // Note: the _computation is not used, since we are not using Tracker server-side + return this.#collection.findOneAsync(selector, options) + } +} export const MeteorTriggersContext: TriggersContext = { MeteorCall, @@ -54,14 +71,14 @@ export const MeteorTriggersContext: TriggersContext = { isClient: false, - AdLibActions, - AdLibPieces, - Parts, - RundownBaselineAdLibActions, - RundownBaselineAdLibPieces, - RundownPlaylists, - Rundowns, - Segments, + AdLibActions: new MeteorTriggersCollectionWrapper(AdLibActions), + AdLibPieces: new MeteorTriggersCollectionWrapper(AdLibPieces), + Parts: new MeteorTriggersCollectionWrapper(Parts), + RundownBaselineAdLibActions: new MeteorTriggersCollectionWrapper(RundownBaselineAdLibActions), + RundownBaselineAdLibPieces: new MeteorTriggersCollectionWrapper(RundownBaselineAdLibPieces), + RundownPlaylists: new MeteorTriggersCollectionWrapper(RundownPlaylists), + Rundowns: new MeteorTriggersCollectionWrapper(Rundowns), + Segments: new MeteorTriggersCollectionWrapper(Segments), hashSingleUseToken, @@ -81,73 +98,92 @@ export const MeteorTriggersContext: TriggersContext = { ) }, - nonreactiveTracker: Tracker.nonreactive, + withComputation: async (_computation, func) => { + // Note: the _computation is not used, since we are not using Tracker server-side + return func() + }, - memoizedIsolatedAutorun: any>( - fnc: T, + memoizedIsolatedAutorun: async ( + computation: TriggerTrackerComputation | null, + fnc: (computation: TriggerTrackerComputation | null, ...args: TArgs) => Promise, _functionName: string, - ...params: Parameters - ): ReturnType => { - return fnc(...(params as any)) + ...params: TArgs + ): Promise => { + return fnc(computation, ...params) }, createContextForRundownPlaylistChain, } -function createContextForRundownPlaylistChain( +async function createContextForRundownPlaylistChain( studioId: StudioId, filterChain: IBaseFilterLink[] -): ReactivePlaylistActionContext | undefined { - const playlist = rundownPlaylistFilter( +): Promise { + const playlist = await rundownPlaylistFilter( studioId, filterChain.filter((link) => link.object === 'rundownPlaylist') as IRundownPlaylistFilterLink[] ) if (!playlist) return undefined - let currentPartId: PartId | null = null, - nextPartId: PartId | null = null, - currentPartInstance: PartInstance | null = null, - currentSegmentPartIds: PartId[] = [], - nextSegmentPartIds: PartId[] = [] - - if (playlist.currentPartInfo) { - currentPartInstance = PartInstances.findOne(playlist.currentPartInfo.partInstanceId) ?? null - const currentPart = currentPartInstance?.part ?? null - if (currentPart) { - currentPartId = currentPart._id - currentSegmentPartIds = Parts.find({ - segmentId: currentPart.segmentId, - }).map((part) => part._id) - } - } - if (playlist.nextPartInfo) { - const nextPart = PartInstances.findOne(playlist.nextPartInfo.partInstanceId)?.part ?? null - if (nextPart) { - nextPartId = nextPart._id - nextSegmentPartIds = Parts.find({ - segmentId: nextPart.segmentId, - }).map((part) => part._id) - } - } + const [currentPartInfo, nextPartInfo] = await Promise.all([ + fetchInfoForSelectedPart(playlist.currentPartInfo), + fetchInfoForSelectedPart(playlist.nextPartInfo), + ]) return { studioId: new DummyReactiveVar(studioId), rundownPlaylistId: new DummyReactiveVar(playlist?._id), rundownPlaylist: new DummyReactiveVar(playlist), - currentRundownId: new DummyReactiveVar(currentPartInstance?.rundownId ?? playlist.rundownIdsInOrder[0] ?? null), - currentPartId: new DummyReactiveVar(currentPartId), - currentSegmentPartIds: new DummyReactiveVar(currentSegmentPartIds), - nextPartId: new DummyReactiveVar(nextPartId), - nextSegmentPartIds: new DummyReactiveVar(nextSegmentPartIds), + currentRundownId: new DummyReactiveVar( + playlist.currentPartInfo?.rundownId ?? playlist.rundownIdsInOrder[0] ?? null + ), + currentPartId: new DummyReactiveVar(currentPartInfo?.partId ?? null), + currentSegmentPartIds: new DummyReactiveVar(currentPartInfo?.segmentPartIds ?? []), + nextPartId: new DummyReactiveVar(nextPartInfo?.partId ?? null), + nextSegmentPartIds: new DummyReactiveVar(nextPartInfo?.segmentPartIds ?? []), currentPartInstanceId: new DummyReactiveVar(playlist.currentPartInfo?.partInstanceId ?? null), } } -function rundownPlaylistFilter( +async function fetchInfoForSelectedPart(partInfo: SelectedPartInstance | null): Promise<{ + partId: PartId + segmentPartIds: PartId[] +} | null> { + if (!partInfo) return null + + const partInstance = (await PartInstances.findOneAsync(partInfo.partInstanceId, { + projection: { + // @ts-expect-error deep property + 'part._id': 1, + segmentId: 1, + }, + })) as (Pick & { part: Pick }) | null + + if (!partInstance) return null + + const partId = partInstance.part._id + const segmentPartIds = await Parts.findFetchAsync( + { + segmentId: partInstance.segmentId, + }, + { + projection: { + _id: 1, + }, + } + ).then((parts) => parts.map((part) => part._id)) + + return { + partId, + segmentPartIds, + } +} + +async function rundownPlaylistFilter( studioId: StudioId, filterChain: IRundownPlaylistFilterLink[] -): DBRundownPlaylist | undefined { +): Promise { const selector: MongoQuery = { $and: [ { @@ -181,5 +217,5 @@ function rundownPlaylistFilter( } }) - return RundownPlaylists.findOne(selector) + return RundownPlaylists.findOneAsync(selector) } diff --git a/meteor/server/api/deviceTriggers/triggersContextCollection.ts b/meteor/server/api/deviceTriggers/triggersContextCollection.ts deleted file mode 100644 index 23711d92bb..0000000000 --- a/meteor/server/api/deviceTriggers/triggersContextCollection.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Meteor } from 'meteor/meteor' -import { Mongo } from 'meteor/mongo' -import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' -import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' -import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' -import { - MongoReadOnlyCollection, - MongoCursor, - FindOptions, - FindOneOptions, -} from '@sofie-automation/meteor-lib/dist/collections/lib' -import { ProtectedString } from '@sofie-automation/corelib/dist/protectedString' -import { getOrCreateMongoCollection } from '../../collections/collection' - -/** - * Create a Mongo Collection for use in the client (has sync apis) - * @param name Name of the collection - */ -export function createSyncReadOnlyMongoCollection }>( - name: CollectionName -): MongoReadOnlyCollection { - const collection = getOrCreateMongoCollection(name) - const wrapped = new WrappedMongoReadOnlyCollection(collection, name) - - // registerClientCollection(name, wrapped) - - return wrapped -} - -class WrappedMongoReadOnlyCollection }> - implements MongoReadOnlyCollection -{ - protected readonly _collection: Mongo.Collection - - public readonly name: string | null - - constructor(collection: Mongo.Collection, name: string | null) { - this._collection = collection - this.name = name - } - - protected get _isMock() { - // @ts-expect-error re-export private property - return this._collection._isMock - } - - public get mockCollection() { - return this._collection - } - - protected wrapMongoError(e: any): never { - const str = stringifyError(e) || 'Unknown MongoDB Error' - throw new Meteor.Error((e && e.error) || 500, `Collection "${this.name}": ${str}`) - } - - find( - selector?: MongoQuery | DBInterface['_id'], - options?: FindOptions - ): MongoCursor { - try { - return this._collection.find((selector ?? {}) as any, options as any) as MongoCursor - } catch (e) { - this.wrapMongoError(e) - } - } - findOne( - selector?: MongoQuery | DBInterface['_id'], - options?: FindOneOptions - ): DBInterface | undefined { - try { - return this._collection.findOne((selector ?? {}) as any, options as any) - } catch (e) { - this.wrapMongoError(e) - } - } -} diff --git a/meteor/server/lib/reactiveMap.ts b/meteor/server/lib/reactiveMap.ts deleted file mode 100644 index 67ec848e42..0000000000 --- a/meteor/server/lib/reactiveMap.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Tracker } from 'meteor/tracker' - -export class ReactiveMap { - private baseMap = new Map() - private dependencyMap = new Map() - private globalDependency = new Tracker.Dependency() - - set(key: string, value: T): void { - const prevVal = this.baseMap.get(key) - this.baseMap.set(key, value) - if (this.dependencyMap.has(key) && prevVal !== value) { - this.dependencyMap.get(key)?.changed() - } else { - this.dependencyMap.set(key, new Tracker.Dependency()) - } - if (prevVal !== value) this.globalDependency.changed() - } - - get(key: string): T | undefined { - if (this.dependencyMap.has(key)) { - this.dependencyMap.get(key)?.depend() - } else { - const dependency = new Tracker.Dependency() - dependency?.depend() - this.dependencyMap.set(key, dependency) - } - return this.baseMap.get(key) - } - - getAll(): { [key: string]: T } { - const result: { [key: string]: T } = {} - for (const [key, value] of this.baseMap.entries()) { - result[key] = value - } - this.globalDependency.depend() - return result - } -} diff --git a/packages/meteor-lib/src/triggers/actionFactory.ts b/packages/meteor-lib/src/triggers/actionFactory.ts index c3ba6d97ba..7796716ffc 100644 --- a/packages/meteor-lib/src/triggers/actionFactory.ts +++ b/packages/meteor-lib/src/triggers/actionFactory.ts @@ -26,8 +26,8 @@ import { import { DeviceActions } from '@sofie-automation/shared-lib/dist/core/model/ShowStyle' import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' import { MountedAdLibTriggerType } from '../api/MountedTriggers' -import { DummyReactiveVar, ReactiveVar } from './reactive-var' -import { TriggersContext } from './triggersContext' +import { DummyReactiveVar, TriggerReactiveVar } from './reactive-var' +import { TriggersContext, TriggerTrackerComputation } from './triggersContext' import { assertNever } from '@sofie-automation/corelib/dist/lib' // as described in this issue: https://github.com/Microsoft/TypeScript/issues/14094 @@ -36,18 +36,18 @@ type Without = { [P in Exclude]?: never } type XOR = T | U extends object ? (Without & U) | (Without & T) : T | U export interface ReactivePlaylistActionContext { - studioId: ReactiveVar - rundownPlaylistId: ReactiveVar - rundownPlaylist: ReactiveVar< + studioId: TriggerReactiveVar + rundownPlaylistId: TriggerReactiveVar + rundownPlaylist: TriggerReactiveVar< Pick > - currentRundownId: ReactiveVar - currentSegmentPartIds: ReactiveVar - nextSegmentPartIds: ReactiveVar - currentPartInstanceId: ReactiveVar - currentPartId: ReactiveVar - nextPartId: ReactiveVar + currentRundownId: TriggerReactiveVar + currentSegmentPartIds: TriggerReactiveVar + nextSegmentPartIds: TriggerReactiveVar + currentPartInstanceId: TriggerReactiveVar + currentPartId: TriggerReactiveVar + nextPartId: TriggerReactiveVar } interface PlainPlaylistContext { @@ -64,11 +64,11 @@ interface PlainStudioContext { showStyleBase: DBShowStyleBase } -type PlainActionContext = XOR +export type PlainActionContext = XOR export type ActionContext = XOR -type ActionExecutor = (t: TFunction, e: any, ctx: ActionContext) => void +type ActionExecutor = (t: TFunction, e: any, ctx: ActionContext) => Promise | void /** * An action compiled down to a single function that can be executed @@ -89,7 +89,7 @@ export interface ExecutableAction { * @extends {ExecutableAction} */ interface PreviewableAction extends ExecutableAction { - preview: (ctx: ReactivePlaylistActionContext) => IWrappedAdLib[] + preview: (ctx: ActionContext, computation: TriggerTrackerComputation | null) => Promise } interface ExecutableAdLibAction extends PreviewableAction { @@ -99,31 +99,35 @@ interface ExecutableAdLibAction extends PreviewableAction { export function isPreviewableAction(action: ExecutableAction): action is PreviewableAction { return action.action && 'preview' in action && typeof action['preview'] === 'function' } -function createRundownPlaylistContext( +async function createRundownPlaylistContext( + computation: TriggerTrackerComputation | null, triggersContext: TriggersContext, context: ActionContext, filterChain: IBaseFilterLink[] -): ReactivePlaylistActionContext | undefined { +): Promise { if (filterChain.length < 1) { return undefined } else if (filterChain[0].object === 'view' && context.rundownPlaylistId) { return context as ReactivePlaylistActionContext } else if (filterChain[0].object === 'view' && context.rundownPlaylist) { const playlistContext = context as PlainPlaylistContext - return { - studioId: new DummyReactiveVar(playlistContext.rundownPlaylist.studioId), - rundownPlaylistId: new DummyReactiveVar(playlistContext.rundownPlaylist._id), - rundownPlaylist: new DummyReactiveVar(playlistContext.rundownPlaylist), - currentRundownId: new DummyReactiveVar(playlistContext.currentRundownId), - currentPartId: new DummyReactiveVar(playlistContext.currentPartId), - nextPartId: new DummyReactiveVar(playlistContext.nextPartId), - currentSegmentPartIds: new DummyReactiveVar(playlistContext.currentSegmentPartIds), - nextSegmentPartIds: new DummyReactiveVar(playlistContext.nextSegmentPartIds), - currentPartInstanceId: new DummyReactiveVar( - playlistContext.rundownPlaylist.currentPartInfo?.partInstanceId ?? null - ), - } + return triggersContext.withComputation(computation, async () => { + return { + studioId: new DummyReactiveVar(playlistContext.rundownPlaylist.studioId), + rundownPlaylistId: new DummyReactiveVar(playlistContext.rundownPlaylist._id), + rundownPlaylist: new DummyReactiveVar(playlistContext.rundownPlaylist), + currentRundownId: new DummyReactiveVar(playlistContext.currentRundownId), + currentPartId: new DummyReactiveVar(playlistContext.currentPartId), + nextPartId: new DummyReactiveVar(playlistContext.nextPartId), + currentSegmentPartIds: new DummyReactiveVar(playlistContext.currentSegmentPartIds), + nextSegmentPartIds: new DummyReactiveVar(playlistContext.nextSegmentPartIds), + currentPartInstanceId: new DummyReactiveVar( + playlistContext.rundownPlaylist.currentPartInfo?.partInstanceId ?? null + ), + } + }) } else if (filterChain[0].object === 'rundownPlaylist' && context.studio) { + // Note: this is only implemented on the server return triggersContext.createContextForRundownPlaylistChain(context.studio._id, filterChain) } else { throw new Error('Invalid filter combination') @@ -148,12 +152,12 @@ function createAdLibAction( return { action: PlayoutActions.adlib, - preview: (ctx) => { - const innerCtx = createRundownPlaylistContext(triggersContext, ctx, filterChain) + preview: async (ctx, computation) => { + const innerCtx = await createRundownPlaylistContext(computation, triggersContext, ctx, filterChain) if (innerCtx) { try { - return compiledAdLibFilter(innerCtx) + return compiledAdLibFilter(innerCtx, computation) } catch (e) { triggersContext.logger.error(e) return [] @@ -162,8 +166,8 @@ function createAdLibAction( return [] } }, - execute: (t, e, ctx) => { - const innerCtx = createRundownPlaylistContext(triggersContext, ctx, filterChain) + execute: async (t, e, ctx) => { + const innerCtx = await createRundownPlaylistContext(null, triggersContext, ctx, filterChain) if (!innerCtx) { triggersContext.logger.warn( @@ -172,93 +176,97 @@ function createAdLibAction( ) return } - const currentPartInstanceId = innerCtx.rundownPlaylist.get().currentPartInfo?.partInstanceId + const currentPartInstanceId = innerCtx.rundownPlaylist.get(null).currentPartInfo?.partInstanceId const sourceLayerIdsToClear: string[] = [] - triggersContext - .nonreactiveTracker(() => compiledAdLibFilter(innerCtx)) - .forEach((wrappedAdLib) => { - switch (wrappedAdLib.type) { - case MountedAdLibTriggerType.adLibPiece: - triggersContext.doUserAction(t, e, UserAction.START_ADLIB, async (e, ts) => - currentPartInstanceId - ? triggersContext.MeteorCall.userAction.segmentAdLibPieceStart( - e, - ts, - innerCtx.rundownPlaylistId.get(), - currentPartInstanceId, - wrappedAdLib.item._id, - false - ) - : ClientAPI.responseSuccess(undefined) - ) - break - case MountedAdLibTriggerType.rundownBaselineAdLibItem: - triggersContext.doUserAction(t, e, UserAction.START_GLOBAL_ADLIB, async (e, ts) => - currentPartInstanceId - ? triggersContext.MeteorCall.userAction.baselineAdLibPieceStart( - e, - ts, - innerCtx.rundownPlaylistId.get(), - currentPartInstanceId, - wrappedAdLib.item._id, - false - ) - : ClientAPI.responseSuccess(undefined) - ) - break - case MountedAdLibTriggerType.adLibAction: - triggersContext.doUserAction(t, e, UserAction.START_ADLIB, async (e, ts) => - triggersContext.MeteorCall.userAction.executeAction( - e, - ts, - innerCtx.rundownPlaylistId.get(), - wrappedAdLib._id, - wrappedAdLib.item.actionId, - wrappedAdLib.item.userData, - (actionArguments && actionArguments.triggerMode) || undefined - ) + + // This withComputation is probably not needed, but it ensures there is no accidental reactivity + const wrappedAdLibs = await triggersContext.withComputation(null, async () => + compiledAdLibFilter(innerCtx, null) + ) + + wrappedAdLibs.forEach((wrappedAdLib) => { + switch (wrappedAdLib.type) { + case MountedAdLibTriggerType.adLibPiece: + triggersContext.doUserAction(t, e, UserAction.START_ADLIB, async (e, ts) => + currentPartInstanceId + ? triggersContext.MeteorCall.userAction.segmentAdLibPieceStart( + e, + ts, + innerCtx.rundownPlaylistId.get(null), + currentPartInstanceId, + wrappedAdLib.item._id, + false + ) + : ClientAPI.responseSuccess(undefined) + ) + break + case MountedAdLibTriggerType.rundownBaselineAdLibItem: + triggersContext.doUserAction(t, e, UserAction.START_GLOBAL_ADLIB, async (e, ts) => + currentPartInstanceId + ? triggersContext.MeteorCall.userAction.baselineAdLibPieceStart( + e, + ts, + innerCtx.rundownPlaylistId.get(null), + currentPartInstanceId, + wrappedAdLib.item._id, + false + ) + : ClientAPI.responseSuccess(undefined) + ) + break + case MountedAdLibTriggerType.adLibAction: + triggersContext.doUserAction(t, e, UserAction.START_ADLIB, async (e, ts) => + triggersContext.MeteorCall.userAction.executeAction( + e, + ts, + innerCtx.rundownPlaylistId.get(null), + wrappedAdLib._id, + wrappedAdLib.item.actionId, + wrappedAdLib.item.userData, + (actionArguments && actionArguments.triggerMode) || undefined ) - break - case MountedAdLibTriggerType.rundownBaselineAdLibAction: - triggersContext.doUserAction(t, e, UserAction.START_GLOBAL_ADLIB, async (e, ts) => - triggersContext.MeteorCall.userAction.executeAction( - e, - ts, - innerCtx.rundownPlaylistId.get(), - wrappedAdLib._id, - wrappedAdLib.item.actionId, - wrappedAdLib.item.userData, - (actionArguments && actionArguments.triggerMode) || undefined - ) + ) + break + case MountedAdLibTriggerType.rundownBaselineAdLibAction: + triggersContext.doUserAction(t, e, UserAction.START_GLOBAL_ADLIB, async (e, ts) => + triggersContext.MeteorCall.userAction.executeAction( + e, + ts, + innerCtx.rundownPlaylistId.get(null), + wrappedAdLib._id, + wrappedAdLib.item.actionId, + wrappedAdLib.item.userData, + (actionArguments && actionArguments.triggerMode) || undefined ) - break - case MountedAdLibTriggerType.clearSourceLayer: - // defer this action to send a single clear action all at once - sourceLayerIdsToClear.push(wrappedAdLib.sourceLayerId) - break - case MountedAdLibTriggerType.sticky: - triggersContext.doUserAction(t, e, UserAction.START_STICKY_PIECE, async (e, ts) => - triggersContext.MeteorCall.userAction.sourceLayerStickyPieceStart( - e, - ts, - innerCtx.rundownPlaylistId.get(), - wrappedAdLib.sourceLayerId // - ) + ) + break + case MountedAdLibTriggerType.clearSourceLayer: + // defer this action to send a single clear action all at once + sourceLayerIdsToClear.push(wrappedAdLib.sourceLayerId) + break + case MountedAdLibTriggerType.sticky: + triggersContext.doUserAction(t, e, UserAction.START_STICKY_PIECE, async (e, ts) => + triggersContext.MeteorCall.userAction.sourceLayerStickyPieceStart( + e, + ts, + innerCtx.rundownPlaylistId.get(null), + wrappedAdLib.sourceLayerId // ) - break - default: - assertNever(wrappedAdLib) - return - } - }) + ) + break + default: + assertNever(wrappedAdLib) + return + } + }) if (currentPartInstanceId && sourceLayerIdsToClear.length > 0) { triggersContext.doUserAction(t, e, UserAction.CLEAR_SOURCELAYER, async (e, ts) => triggersContext.MeteorCall.userAction.sourceLayerOnPartStop( e, ts, - innerCtx.rundownPlaylistId.get(), + innerCtx.rundownPlaylistId.get(null), currentPartInstanceId, sourceLayerIdsToClear ) @@ -409,9 +417,10 @@ function createUserActionWithCtx( ): ExecutableAction { return { action: action.action, - execute: (t, e, ctx) => { - const innerCtx = triggersContext.nonreactiveTracker(() => - createRundownPlaylistContext(triggersContext, ctx, action.filterChain) + execute: async (t, e, ctx) => { + // This outer withComputation is probably not needed, but it ensures there is no accidental reactivity + const innerCtx = await triggersContext.withComputation(null, async () => + createRundownPlaylistContext(null, triggersContext, ctx, action.filterChain) ) if (innerCtx) { triggersContext.doUserAction(t, e, userAction, async (e, ts) => userActionExec(e, ts, innerCtx)) @@ -450,7 +459,7 @@ export function createAction( triggersContext.MeteorCall.userAction.forceResetAndActivate( e, ts, - ctx.rundownPlaylistId.get(), + ctx.rundownPlaylistId.get(null), !!action.rehearsal || false ) ) @@ -469,7 +478,7 @@ export function createAction( triggersContext.MeteorCall.userAction.activate( e, ts, - ctx.rundownPlaylistId.get(), + ctx.rundownPlaylistId.get(null), !!action.rehearsal || false ) ) @@ -484,7 +493,7 @@ export function createAction( action, UserAction.DEACTIVATE_RUNDOWN_PLAYLIST, async (e, ts, ctx) => - triggersContext.MeteorCall.userAction.deactivate(e, ts, ctx.rundownPlaylistId.get()) + triggersContext.MeteorCall.userAction.deactivate(e, ts, ctx.rundownPlaylistId.get(null)) ) case PlayoutActions.activateAdlibTestingMode: return createUserActionWithCtx( @@ -492,12 +501,12 @@ export function createAction( action, UserAction.ACTIVATE_ADLIB_TESTING, async (e, ts, ctx) => { - const rundownId = ctx.currentRundownId.get() + const rundownId = ctx.currentRundownId.get(null) if (rundownId) { return triggersContext.MeteorCall.userAction.activateAdlibTestingMode( e, ts, - ctx.rundownPlaylistId.get(), + ctx.rundownPlaylistId.get(null), rundownId ) } else { @@ -513,21 +522,26 @@ export function createAction( triggersContext.MeteorCall.userAction.take( e, ts, - ctx.rundownPlaylistId.get(), - ctx.currentPartInstanceId.get() + ctx.rundownPlaylistId.get(null), + ctx.currentPartInstanceId.get(null) ) ) } case PlayoutActions.hold: return createUserActionWithCtx(triggersContext, action, UserAction.ACTIVATE_HOLD, async (e, ts, ctx) => - triggersContext.MeteorCall.userAction.activateHold(e, ts, ctx.rundownPlaylistId.get(), !!action.undo) + triggersContext.MeteorCall.userAction.activateHold( + e, + ts, + ctx.rundownPlaylistId.get(null), + !!action.undo + ) ) case PlayoutActions.disableNextPiece: return createUserActionWithCtx(triggersContext, action, UserAction.DISABLE_NEXT_PIECE, async (e, ts, ctx) => triggersContext.MeteorCall.userAction.disableNextPiece( e, ts, - ctx.rundownPlaylistId.get(), + ctx.rundownPlaylistId.get(null), !!action.undo ) ) @@ -546,7 +560,7 @@ export function createAction( e, ts, triggersContext.hashSingleUseToken(tokenResult.result), - ctx.rundownPlaylistId.get(), + ctx.rundownPlaylistId.get(null), `action`, false ) @@ -558,7 +572,7 @@ export function createAction( triggersContext.MeteorCall.userAction.moveNext( e, ts, - ctx.rundownPlaylistId.get(), + ctx.rundownPlaylistId.get(null), action.parts ?? 0, action.segments ?? 0 ) @@ -574,7 +588,11 @@ export function createAction( async (e, ts, ctx) => // TODO: Needs some handling of the response. Perhaps this should switch to // an event on the RundownViewEventBus, if ran on the client? - triggersContext.MeteorCall.userAction.resyncRundownPlaylist(e, ts, ctx.rundownPlaylistId.get()) + triggersContext.MeteorCall.userAction.resyncRundownPlaylist( + e, + ts, + ctx.rundownPlaylistId.get(null) + ) ) } case PlayoutActions.resetRundownPlaylist: @@ -586,7 +604,11 @@ export function createAction( action, UserAction.RESET_RUNDOWN_PLAYLIST, async (e, ts, ctx) => - triggersContext.MeteorCall.userAction.resetRundownPlaylist(e, ts, ctx.rundownPlaylistId.get()) + triggersContext.MeteorCall.userAction.resetRundownPlaylist( + e, + ts, + ctx.rundownPlaylistId.get(null) + ) ) } case PlayoutActions.resyncRundownPlaylist: @@ -595,14 +617,14 @@ export function createAction( action, UserAction.RESYNC_RUNDOWN_PLAYLIST, async (e, ts, ctx) => - triggersContext.MeteorCall.userAction.resyncRundownPlaylist(e, ts, ctx.rundownPlaylistId.get()) + triggersContext.MeteorCall.userAction.resyncRundownPlaylist(e, ts, ctx.rundownPlaylistId.get(null)) ) case PlayoutActions.switchRouteSet: return createUserActionWithCtx(triggersContext, action, UserAction.SWITCH_ROUTE_SET, async (e, ts, ctx) => triggersContext.MeteorCall.userAction.switchRouteSet( e, ts, - ctx.studioId.get(), + ctx.studioId.get(null), action.routeSetId, action.state ) diff --git a/packages/meteor-lib/src/triggers/actionFilterChainCompilers.ts b/packages/meteor-lib/src/triggers/actionFilterChainCompilers.ts index eb2b9e47cb..d8afa19d94 100644 --- a/packages/meteor-lib/src/triggers/actionFilterChainCompilers.ts +++ b/packages/meteor-lib/src/triggers/actionFilterChainCompilers.ts @@ -25,7 +25,7 @@ import { IWrappedAdLibBase } from '@sofie-automation/shared-lib/dist/input-gatew import { MountedAdLibTriggerType } from '../api/MountedTriggers' import { assertNever, generateTranslation } from '@sofie-automation/corelib/dist/lib' import { FindOptions } from '../collections/lib' -import { TriggersContext } from './triggersContext' +import { TriggersContext, TriggerTrackerComputation } from './triggersContext' export type AdLibFilterChainLink = IRundownPlaylistFilterLink | IGUIContextFilterLink | IAdLibFilterLink @@ -490,7 +490,7 @@ export function compileAdLibFilter( triggersContext: TriggersContext, filterChain: AdLibFilterChainLink[], sourceLayers: SourceLayers -): (context: ReactivePlaylistActionContext) => IWrappedAdLib[] { +): (context: ReactivePlaylistActionContext, computation: TriggerTrackerComputation | null) => Promise { const onlyAdLibLinks = filterChain.filter((link) => link.object === 'adLib') as IAdLibFilterLink[] const adLibPieceTypeFilter = compileAdLibPieceFilter(onlyAdLibLinks, sourceLayers) const adLibActionTypeFilter = compileAdLibActionFilter(onlyAdLibLinks, sourceLayers) @@ -498,23 +498,23 @@ export function compileAdLibFilter( const clearAdLibs = compileAndRunClearFilter(onlyAdLibLinks, sourceLayers) const stickyAdLibs = compileAndRunStickyFilter(onlyAdLibLinks, sourceLayers) - return (context: ReactivePlaylistActionContext) => { + return async (context: ReactivePlaylistActionContext, computation: TriggerTrackerComputation | null) => { let rundownBaselineAdLibItems: IWrappedAdLib[] = [] let adLibPieces: IWrappedAdLib[] = [] let rundownBaselineAdLibActions: IWrappedAdLib[] = [] let adLibActions: IWrappedAdLib[] = [] const segmentPartIds = adLibPieceTypeFilter.segment === 'current' - ? context.currentSegmentPartIds.get() + ? context.currentSegmentPartIds.get(computation) : adLibPieceTypeFilter.segment === 'next' - ? context.nextSegmentPartIds.get() + ? context.nextSegmentPartIds.get(computation) : undefined const singlePartId = adLibPieceTypeFilter.part === 'current' - ? context.currentPartId.get() + ? context.currentPartId.get(computation) : adLibPieceTypeFilter.part === 'next' - ? context.nextPartId.get() + ? context.nextPartId.get(computation) : undefined /** Note: undefined means that all parts are to be considered */ @@ -554,25 +554,31 @@ export function compileAdLibFilter( } } - const currentRundownId = context.currentRundownId.get() + const currentRundownId = context.currentRundownId.get(computation) if (!skip && currentRundownId) { if (adLibPieceTypeFilter.global === undefined || adLibPieceTypeFilter.global === true) - rundownBaselineAdLibItems = triggersContext.RundownBaselineAdLibPieces.find( - { - ...adLibPieceTypeFilter.selector, - ...currentNextOverride, - rundownId: currentRundownId, - } as MongoQuery, - adLibPieceTypeFilter.options + rundownBaselineAdLibItems = ( + await triggersContext.RundownBaselineAdLibPieces.findFetchAsync( + computation, + { + ...adLibPieceTypeFilter.selector, + ...currentNextOverride, + rundownId: currentRundownId, + } as MongoQuery, + adLibPieceTypeFilter.options + ) ).map((item) => wrapAdLibPiece(item, MountedAdLibTriggerType.rundownBaselineAdLibItem)) if (adLibPieceTypeFilter.global === undefined || adLibPieceTypeFilter.global === false) - adLibPieces = triggersContext.AdLibPieces.find( - { - ...adLibPieceTypeFilter.selector, - ...currentNextOverride, - rundownId: currentRundownId, - } as MongoQuery, - adLibPieceTypeFilter.options + adLibPieces = ( + await triggersContext.AdLibPieces.findFetchAsync( + computation, + { + ...adLibPieceTypeFilter.selector, + ...currentNextOverride, + rundownId: currentRundownId, + } as MongoQuery, + adLibPieceTypeFilter.options + ) ).map((item) => wrapAdLibPiece(item, MountedAdLibTriggerType.adLibPiece)) } } @@ -591,27 +597,33 @@ export function compileAdLibFilter( } } - const currentRundownId = context.currentRundownId.get() + const currentRundownId = context.currentRundownId.get(computation) if (!skip && currentRundownId) { if (adLibActionTypeFilter.global === undefined || adLibActionTypeFilter.global === true) - rundownBaselineAdLibActions = triggersContext.RundownBaselineAdLibActions.find( - { - ...adLibActionTypeFilter.selector, - ...currentNextOverride, - rundownId: currentRundownId, - } as MongoQuery, - adLibActionTypeFilter.options + rundownBaselineAdLibActions = ( + await triggersContext.RundownBaselineAdLibActions.findFetchAsync( + computation, + { + ...adLibActionTypeFilter.selector, + ...currentNextOverride, + rundownId: currentRundownId, + } as MongoQuery, + adLibActionTypeFilter.options + ) ).map((item) => wrapRundownBaselineAdLibAction(item, MountedAdLibTriggerType.rundownBaselineAdLibAction) ) if (adLibActionTypeFilter.global === undefined || adLibActionTypeFilter.global === false) - adLibActions = triggersContext.AdLibActions.find( - { - ...adLibActionTypeFilter.selector, - ...currentNextOverride, - rundownId: currentRundownId, - } as MongoQuery, - adLibActionTypeFilter.options + adLibActions = ( + await triggersContext.AdLibActions.findFetchAsync( + computation, + { + ...adLibActionTypeFilter.selector, + ...currentNextOverride, + rundownId: currentRundownId, + } as MongoQuery, + adLibActionTypeFilter.options + ) ).map((item) => wrapAdLibAction(item, MountedAdLibTriggerType.adLibAction)) } } @@ -624,38 +636,49 @@ export function compileAdLibFilter( // Note: We need to return an array from within memoizedIsolatedAutorun, // because _.isEqual (used in memoizedIsolatedAutorun) doesn't work with Maps.. - const rundownPlaylistId = context.rundownPlaylistId.get() - const rundownRanks = triggersContext.memoizedIsolatedAutorun(() => { - const playlist = triggersContext.RundownPlaylists.findOne(rundownPlaylistId, { - projection: { - rundownIdsInOrder: 1, - }, - }) as Pick | undefined - - if (playlist?.rundownIdsInOrder) { - return playlist.rundownIdsInOrder - } else { - const rundowns = triggersContext.Rundowns.find( + const rundownPlaylistId = context.rundownPlaylistId.get(computation) + const rundownRanks = await triggersContext.memoizedIsolatedAutorun( + computation, + async (computation) => { + const playlist = (await triggersContext.RundownPlaylists.findOneAsync( + computation, + rundownPlaylistId, { - playlistId: rundownPlaylistId, - }, - { - fields: { - _id: 1, + projection: { + rundownIdsInOrder: 1, }, } - ).fetch() as Pick[] - - return rundowns.map((r) => r._id) - } - }, `rundownsRanksForPlaylist_${rundownPlaylistId}`) + )) as Pick | undefined + + if (playlist?.rundownIdsInOrder) { + return playlist.rundownIdsInOrder + } else { + const rundowns = (await triggersContext.Rundowns.findFetchAsync( + computation, + { + playlistId: rundownPlaylistId, + }, + { + fields: { + _id: 1, + }, + } + )) as Pick[] + + return rundowns.map((r) => r._id) + } + }, + `rundownsRanksForPlaylist_${rundownPlaylistId}` + ) rundownRanks.forEach((id, index) => { rundownRankMap.set(id, index) }) - const segmentRanks = triggersContext.memoizedIsolatedAutorun( - () => - triggersContext.Segments.find( + const segmentRanks = await triggersContext.memoizedIsolatedAutorun( + computation, + async (computation) => + (await triggersContext.Segments.findFetchAsync( + computation, { rundownId: { $in: Array.from(rundownRankMap.keys()) }, }, @@ -665,42 +688,48 @@ export function compileAdLibFilter( _rank: 1, }, } - ).fetch() as Pick[], + )) as Pick[], `segmentRanksForRundowns_${Array.from(rundownRankMap.keys()).join(',')}` ) segmentRanks.forEach((segment) => { segmentRankMap.set(segment._id, segment._rank) }) - const partRanks = triggersContext.memoizedIsolatedAutorun(() => { - if (!partFilter) { - return triggersContext.Parts.find( - { - rundownId: { $in: Array.from(rundownRankMap.keys()) }, - }, - { - fields: { - _id: 1, - segmentId: 1, - rundownId: 1, - _rank: 1, - }, - } - ).fetch() as Pick[] - } else { - return triggersContext.Parts.find( - { _id: { $in: partFilter } }, - { - fields: { - _id: 1, - segmentId: 1, - rundownId: 1, - _rank: 1, + const partRanks = await triggersContext.memoizedIsolatedAutorun( + computation, + async (computation) => { + if (!partFilter) { + return (await triggersContext.Parts.findFetchAsync( + computation, + { + rundownId: { $in: Array.from(rundownRankMap.keys()) }, }, - } - ).fetch() as Pick[] - } - }, `partRanks_${JSON.stringify(partFilter ?? rundownRankMap.keys())}`) + { + fields: { + _id: 1, + segmentId: 1, + rundownId: 1, + _rank: 1, + }, + } + )) as Pick[] + } else { + return (await triggersContext.Parts.findFetchAsync( + computation, + { _id: { $in: partFilter } }, + { + fields: { + _id: 1, + segmentId: 1, + rundownId: 1, + _rank: 1, + }, + } + )) as Pick[] + } + }, + `partRanks_${JSON.stringify(partFilter ?? rundownRankMap.keys())}` + ) partRanks.forEach((part) => { partRankMap.set(part._id, part) diff --git a/packages/meteor-lib/src/triggers/reactive-var.ts b/packages/meteor-lib/src/triggers/reactive-var.ts index f9d7d58758..765174dd7f 100644 --- a/packages/meteor-lib/src/triggers/reactive-var.ts +++ b/packages/meteor-lib/src/triggers/reactive-var.ts @@ -1,9 +1,11 @@ +import type { TriggerTrackerComputation } from './triggersContext' + // Copied from Meteor -export interface ReactiveVar { +export interface TriggerReactiveVar { /** * Returns the current value of the ReactiveVar, establishing a reactive dependency. */ - get(): T + get(computation: TriggerTrackerComputation | null): T /** * Sets the current value of the ReactiveVar, invalidating the Computations that called `get` if `newValue` is different from the old value. */ @@ -14,7 +16,7 @@ export interface ReactiveVar { * This just looks like a ReactiveVar, but is not reactive. * It's used to use the same interface/typings, but when code is run on both client and server side. * */ -export class DummyReactiveVar implements ReactiveVar { +export class DummyReactiveVar implements TriggerReactiveVar { constructor(private value: T) {} public get(): T { return this.value diff --git a/packages/meteor-lib/src/triggers/triggersContext.ts b/packages/meteor-lib/src/triggers/triggersContext.ts index 4c94b4ac52..94b179cb77 100644 --- a/packages/meteor-lib/src/triggers/triggersContext.ts +++ b/packages/meteor-lib/src/triggers/triggersContext.ts @@ -2,7 +2,7 @@ import { UserAction } from '../userAction' import { IMeteorCall } from '../api/methods' import { Time } from '@sofie-automation/shared-lib/dist/lib/lib' import { ClientAPI } from '../api/client' -import { MongoReadOnlyCollection } from '../collections/lib' +import { FindOneOptions, FindOptions } from '../collections/lib' import { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' @@ -16,6 +16,37 @@ import { IBaseFilterLink } from '@sofie-automation/blueprints-integration' import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { ReactivePlaylistActionContext } from './actionFactory' import { TFunction } from 'i18next' +import { ProtectedString } from '@sofie-automation/corelib/dist/protectedString' +import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' + +/** + * A opaque type that is used in the meteor-lib api instead of implementation specific computations. + * This should be treated as equivalent to the Meteor `Tracker.Computation` type. + */ +export type TriggerTrackerComputation = { __internal: true } + +export interface TriggersAsyncCollection }> { + /** + * Find and return multiple documents + * @param selector A query describing the documents to find + * @param options Options for the operation + */ + findFetchAsync( + computation: TriggerTrackerComputation | null, + selector: MongoQuery, + options?: FindOptions + ): Promise> + + /** + * Finds the first document that matches the selector, as ordered by sort and skip options. Returns `undefined` if no matching document is found. + * @param selector A query describing the documents to find + */ + findOneAsync( + computation: TriggerTrackerComputation | null, + selector: MongoQuery | DBInterface['_id'], + options?: FindOneOptions + ): Promise +} export interface TriggersContext { readonly MeteorCall: IMeteorCall @@ -24,14 +55,14 @@ export interface TriggersContext { readonly isClient: boolean - readonly AdLibActions: MongoReadOnlyCollection - readonly AdLibPieces: MongoReadOnlyCollection - readonly Parts: MongoReadOnlyCollection - readonly RundownBaselineAdLibActions: MongoReadOnlyCollection - readonly RundownBaselineAdLibPieces: MongoReadOnlyCollection - readonly RundownPlaylists: MongoReadOnlyCollection - readonly Rundowns: MongoReadOnlyCollection - readonly Segments: MongoReadOnlyCollection + readonly AdLibActions: TriggersAsyncCollection + readonly AdLibPieces: TriggersAsyncCollection + readonly Parts: TriggersAsyncCollection + readonly RundownBaselineAdLibActions: TriggersAsyncCollection + readonly RundownBaselineAdLibPieces: TriggersAsyncCollection + readonly RundownPlaylists: TriggersAsyncCollection + readonly Rundowns: TriggersAsyncCollection + readonly Segments: TriggersAsyncCollection hashSingleUseToken(token: string): string @@ -44,16 +75,28 @@ export interface TriggersContext { _okMessage?: string ): void - nonreactiveTracker(func: () => T): T + /** + * Equivalent to the Meteor `Tracker.withComputation` function, but implementation specific. + * Use this to ensure that a function is run as part of the provided computation. + */ + withComputation(computation: TriggerTrackerComputation | null, func: () => Promise): Promise - memoizedIsolatedAutorun any>( - fnc: T, + /** + * Create a reactive computation that will be run independently of the outer one. If the same function (using the same + * name and parameters) will be used again, this computation will only be computed once on invalidation and it's + * result will be memoized and reused on every other call. + * + * This will be run as part of the provided computation, and passes the inner computation to the function. + */ + memoizedIsolatedAutorun( + computation: TriggerTrackerComputation | null, + fnc: (computation: TriggerTrackerComputation | null, ...args: TArgs) => Promise, functionName: string, - ...params: Parameters - ): ReturnType + ...params: TArgs + ): Promise createContextForRundownPlaylistChain( _studioId: StudioId, _filterChain: IBaseFilterLink[] - ): ReactivePlaylistActionContext | undefined + ): Promise } diff --git a/packages/webui/src/client/lib/ReactMeteorData/ReactMeteorData.tsx b/packages/webui/src/client/lib/ReactMeteorData/ReactMeteorData.tsx index 5500e01147..4e2b7db455 100644 --- a/packages/webui/src/client/lib/ReactMeteorData/ReactMeteorData.tsx +++ b/packages/webui/src/client/lib/ReactMeteorData/ReactMeteorData.tsx @@ -344,17 +344,56 @@ export type Translated = T & WithTranslation * @param {K} [initial] An optional, initial state of the tracker. If not provided, the tracker may return undefined. * @return {*} {(T | K)} */ -export function useTracker(autorun: () => T, deps: React.DependencyList): T | undefined -export function useTracker(autorun: () => T, deps: React.DependencyList, initial: T): T +export function useTracker( + autorun: (computation: Tracker.Computation) => T, + deps: React.DependencyList +): T | undefined +export function useTracker( + autorun: (computation: Tracker.Computation) => T, + deps: React.DependencyList, + initial: T +): T export function useTracker( - autorun: () => T, + autorun: (computation: Tracker.Computation) => T, + deps: React.DependencyList, + initial?: K +): T | K { + const [meteorData, setMeteorData] = useState(initial as K) + + useEffect(() => { + const computation = Tracker.nonreactive(() => + Tracker.autorun((innerComputation) => setMeteorData(autorun(innerComputation))) + ) + return () => computation.stop() + }, deps) + + return meteorData +} + +/** + * A Meteor Tracker hook that allows using React Functional Components and the Hooks API with Meteor Tracker + * + * This is an alternate implementation which supports promises in the autorun function, and will preserve the previous value until the promise resolves. + * + * @param {() => Promise} autorun The autorun function to be run. + * @param {React.DependencyList} [deps] A required list of dependencies to limit the tracker re-running. Can be left empty, if tracker + * has no external dependencies and should only be rerun when it's invalidated. + * @param {K} [initial] An optional, initial state of the tracker. If not provided, the tracker may return undefined. + * @return {*} {(T | K)} + */ +export function useTrackerAsync( + autorun: (computation: Tracker.Computation) => Promise, deps: React.DependencyList, initial?: K ): T | K { const [meteorData, setMeteorData] = useState(initial as K) useEffect(() => { - const computation = Tracker.nonreactive(() => Tracker.autorun(() => setMeteorData(autorun()))) + const computation = Tracker.nonreactive(() => + Tracker.autorun(async (innerComputation) => { + setMeteorData(await autorun(innerComputation)) + }) + ) return () => computation.stop() }, deps) diff --git a/packages/webui/src/client/lib/memoizedIsolatedAutorun.ts b/packages/webui/src/client/lib/memoizedIsolatedAutorun.ts index 992654c5cb..41c10bc649 100644 --- a/packages/webui/src/client/lib/memoizedIsolatedAutorun.ts +++ b/packages/webui/src/client/lib/memoizedIsolatedAutorun.ts @@ -1,6 +1,8 @@ +import { isPromise } from '@sofie-automation/shared-lib/dist/lib/lib' import { Meteor } from 'meteor/meteor' import { Tracker } from 'meteor/tracker' import _ from 'underscore' +import { getRandomString } from './tempLib' const isolatedAutorunsMem: { [key: string]: { @@ -78,3 +80,86 @@ export function memoizedIsolatedAutorun any>( // @ts-expect-error it is assigned by the tracker return result } + +interface IsolatedAsyncAutorunState { + computationId: string + dependancy: Tracker.Dependency + value: any +} + +const isolatedAsyncAutorunsMem: { + [key: string]: IsolatedAsyncAutorunState +} = {} + +export async function memoizedIsolatedAutorunAsync( + parentComputation: Tracker.Computation | null, + fnc: (computation: Tracker.Computation, ...args: TArgs) => Promise, + functionName: string, + ...params: TArgs +): Promise { + function hashFncAndParams(fName: string, p: any): string { + return fName + '_' + JSON.stringify(p) + } + + const fId = hashFncAndParams(functionName, params) + // Computation is already running, depend on it + if (isolatedAsyncAutorunsMem[fId]) { + const result = isolatedAsyncAutorunsMem[fId].value + isolatedAsyncAutorunsMem[fId].dependancy.depend(parentComputation) + + return result + } + + // Setup the computation + const computationId = getRandomString() + const dep = new Tracker.Dependency() + dep.depend(parentComputation) + const computation = Tracker.nonreactive(() => { + const computationState: IsolatedAsyncAutorunState = { + computationId, + dependancy: dep, + value: null, // Filled in later + } + + const computation = Tracker.autorun(async (innerComputation) => { + // Start executing the function + const rawValue: Promise = fnc(innerComputation, ...params) + + // Fetch the previous value and the new value + const oldValue = computationState.value + const newValue = await rawValue + + // If the old value is an unresolved promise, we can't compare it + const oldRealValue = isPromise(oldValue) ? null : oldValue + + // If the values are different, invalidate the dependancy + // Do this even for the first run, as other listeners might have joined while the promise was resolving + if (!_.isEqual(oldRealValue, newValue)) { + dep.changed() + } + + return newValue as void // Tracker.autorun isn't generic + }) + computation.onStop(() => { + // Only delete if it is this computation that is stopping + if (isolatedAsyncAutorunsMem[fId]?.computationId === computationId) { + delete isolatedAsyncAutorunsMem[fId] + } + }) + + // Store the first value + computationState.value = computation.firstRunPromise + isolatedAsyncAutorunsMem[fId] = computationState + + return computation + }) + const gc = Meteor.setInterval(() => { + if (!dep.hasDependents()) { + Meteor.clearInterval(gc) + computation.stop() + } + }, 5000) + + // Return the promise of the first value + return computation.firstRunPromise as TRes // Tracker.autorun isn't generic +} diff --git a/packages/webui/src/client/lib/notifications/notifications.ts b/packages/webui/src/client/lib/notifications/notifications.ts index e1df7695e4..db179738f8 100644 --- a/packages/webui/src/client/lib/notifications/notifications.ts +++ b/packages/webui/src/client/lib/notifications/notifications.ts @@ -116,7 +116,7 @@ export class NotifierHandle { this.result = source().get() notificationsDep.changed() }) - }) as any as Tracker.Computation + }) notifiers[notifierId] = this } diff --git a/packages/webui/src/client/lib/reactiveData/reactiveDataHelper.ts b/packages/webui/src/client/lib/reactiveData/reactiveDataHelper.ts index 7384c8dee0..119e70a8c4 100644 --- a/packages/webui/src/client/lib/reactiveData/reactiveDataHelper.ts +++ b/packages/webui/src/client/lib/reactiveData/reactiveDataHelper.ts @@ -83,7 +83,7 @@ export abstract class WithManagedTracker { const comp = Tracker.autorun(func, options) this._autoruns.push(comp) return comp - }) as any as Tracker.Computation + }) } } diff --git a/packages/webui/src/client/lib/triggers/TriggersHandler.tsx b/packages/webui/src/client/lib/triggers/TriggersHandler.tsx index 5c28ff8f54..6d3173ba62 100644 --- a/packages/webui/src/client/lib/triggers/TriggersHandler.tsx +++ b/packages/webui/src/client/lib/triggers/TriggersHandler.tsx @@ -45,7 +45,7 @@ import { RundownPlaylistCollectionUtil } from '../../collections/rundownPlaylist import { catchError } from '../lib' import { logger } from '../logging' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { UiTriggersContext } from './triggersContext' +import { toTriggersComputation, toTriggersReactiveVar, UiTriggersContext } from './triggersContext' type HotkeyTriggerListener = (e: KeyboardEvent) => void @@ -94,30 +94,42 @@ function createAction( actions: SomeAction[], showStyleBase: UIShowStyleBase, t: TFunction, - collectContext: () => ReactivePlaylistActionContext | null + collectContext: (computation: Tracker.Computation | null) => ReactivePlaylistActionContext | null ): { listener: HotkeyTriggerListener - preview: () => IWrappedAdLib[] + preview: (computation: Tracker.Computation) => Promise } { const executableActions = actions.map((value) => libCreateAction(UiTriggersContext, value, showStyleBase.sourceLayers) ) return { - preview: () => { - const ctx = collectContext() - if (ctx) { - return flatten(executableActions.map((action) => (isPreviewableAction(action) ? action.preview(ctx) : []))) - } else { - return [] - } + preview: async (computation: Tracker.Computation) => { + const trackerComputation = toTriggersComputation(computation) + const ctx = collectContext(computation) + if (!ctx) return [] + + return flatten( + await Promise.all( + executableActions.map( + async (action): Promise => + isPreviewableAction(action) ? action.preview(ctx, trackerComputation) : [] + ) + ) + ) }, listener: (e) => { e.preventDefault() e.stopPropagation() - const ctx = collectContext() + const ctx = collectContext(null) if (ctx) { - executableActions.forEach((action) => action.execute(t, e, ctx)) + executableActions.forEach((action) => + Promise.resolve() + .then(async () => action.execute(t, e, ctx)) + .catch((e) => { + logger.error(`Execution Triggered Action "${_id}" failed: ${e}`) + }) + ) } }, } @@ -128,8 +140,8 @@ const rundownPlaylistContext: ReactiveVar function setRundownPlaylistContext(ctx: ReactivePlaylistActionContext | null) { rundownPlaylistContext.set(ctx) } -function getCurrentContext(): ReactivePlaylistActionContext | null { - return rundownPlaylistContext.get() +function getCurrentContext(computation: Tracker.Computation | null): ReactivePlaylistActionContext | null { + return rundownPlaylistContext.get(computation ?? undefined) } export const MountedAdLibTriggers = createInMemorySyncMongoCollection( @@ -145,10 +157,12 @@ export function isMountedAdLibTrigger( return 'targetId' in mountedTrigger && !!mountedTrigger['targetId'] } -function isolatedAutorunWithCleanup(autorun: () => void | (() => void)): Tracker.Computation { +function isolatedAutorunWithCleanup( + autorun: (computation: Tracker.Computation) => Promise void)> +): Tracker.Computation { return Tracker.nonreactive(() => - Tracker.autorun((computation) => { - const cleanUp = autorun() + Tracker.autorun(async (computation) => { + const cleanUp = await autorun(computation) if (typeof cleanUp === 'function') { computation.onInvalidate(cleanUp) @@ -322,15 +336,17 @@ export const TriggersHandler: React.FC = function TriggersHandler( let context = rundownPlaylistContext.get() if (context === null) { context = { - studioId: new ReactiveVar(props.studioId), - rundownPlaylistId: new ReactiveVar(playlist._id), - rundownPlaylist: new ReactiveVar(playlist), - currentRundownId: new ReactiveVar(props.currentRundownId), - currentPartId: new ReactiveVar(props.currentPartId), - nextPartId: new ReactiveVar(props.nextPartId), - currentSegmentPartIds: new ReactiveVar(props.currentSegmentPartIds), - nextSegmentPartIds: new ReactiveVar(props.nextSegmentPartIds), - currentPartInstanceId: new ReactiveVar(playlist.currentPartInfo?.partInstanceId ?? null), + studioId: toTriggersReactiveVar(new ReactiveVar(props.studioId)), + rundownPlaylistId: toTriggersReactiveVar(new ReactiveVar(playlist._id)), + rundownPlaylist: toTriggersReactiveVar(new ReactiveVar(playlist)), + currentRundownId: toTriggersReactiveVar(new ReactiveVar(props.currentRundownId)), + currentPartId: toTriggersReactiveVar(new ReactiveVar(props.currentPartId)), + nextPartId: toTriggersReactiveVar(new ReactiveVar(props.nextPartId)), + currentSegmentPartIds: toTriggersReactiveVar(new ReactiveVar(props.currentSegmentPartIds)), + nextSegmentPartIds: toTriggersReactiveVar(new ReactiveVar(props.nextSegmentPartIds)), + currentPartInstanceId: toTriggersReactiveVar( + new ReactiveVar(playlist.currentPartInfo?.partInstanceId ?? null) + ), } rundownPlaylistContext.set(context) } else { @@ -444,10 +460,10 @@ export const TriggersHandler: React.FC = function TriggersHandler( const hotkeyFinalKeys = hotkeyTriggers.map((key) => getFinalKey(key)) previewAutoruns.push( - isolatedAutorunWithCleanup(() => { + isolatedAutorunWithCleanup(async (computation) => { let previewAdLibs: IWrappedAdLib[] = [] try { - previewAdLibs = action.preview() + previewAdLibs = await action.preview(computation) } catch (e) { logger.error(e) } diff --git a/packages/webui/src/client/lib/triggers/triggersContext.ts b/packages/webui/src/client/lib/triggers/triggersContext.ts index 1fe46fc9f3..fc922c45e6 100644 --- a/packages/webui/src/client/lib/triggers/triggersContext.ts +++ b/packages/webui/src/client/lib/triggers/triggersContext.ts @@ -1,9 +1,12 @@ -import { TriggersContext } from '@sofie-automation/meteor-lib/dist/triggers/triggersContext' +import { + TriggersAsyncCollection, + TriggersContext, + TriggerTrackerComputation, +} from '@sofie-automation/meteor-lib/dist/triggers/triggersContext' import { hashSingleUseToken } from '../lib' import { MeteorCall } from '../meteorApi' import { IBaseFilterLink } from '@sofie-automation/blueprints-integration' import { doUserAction } from '../clientUserAction' -import { memoizedIsolatedAutorun } from '../memoizedIsolatedAutorun' import { Tracker } from 'meteor/tracker' import { AdLibActions, @@ -18,6 +21,42 @@ import { import { logger } from '../logging' import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { ReactivePlaylistActionContext } from '@sofie-automation/meteor-lib/dist/triggers/actionFactory' +import { FindOneOptions, MongoReadOnlyCollection } from '../../collections/lib' +import { ProtectedString } from '../tempLib' +import { ReactiveVar as MeteorReactiveVar } from 'meteor/reactive-var' +import { TriggerReactiveVar } from '@sofie-automation/meteor-lib/dist/triggers/reactive-var' +import { FindOptions, MongoQuery } from '@sofie-automation/corelib/dist/mongo' +import { memoizedIsolatedAutorunAsync } from '../memoizedIsolatedAutorun' + +class UiTriggersCollectionWrapper }> + implements TriggersAsyncCollection +{ + readonly #collection: MongoReadOnlyCollection + + constructor(collection: MongoReadOnlyCollection) { + this.#collection = collection + } + + async findFetchAsync( + computation: TriggerTrackerComputation | null, + selector: MongoQuery, + options?: FindOptions + ): Promise> { + return Tracker.withComputation(computation as Tracker.Computation | null, async () => { + return this.#collection.find(selector, options).fetch() + }) + } + + async findOneAsync( + computation: TriggerTrackerComputation | null, + selector: MongoQuery | DBInterface['_id'], + options?: FindOneOptions + ): Promise { + return Tracker.withComputation(computation as Tracker.Computation | null, async () => { + return this.#collection.findOne(selector, options) + }) + } +} export const UiTriggersContext: TriggersContext = { MeteorCall, @@ -26,29 +65,51 @@ export const UiTriggersContext: TriggersContext = { isClient: true, - AdLibActions, - AdLibPieces, - Parts, - RundownBaselineAdLibActions, - RundownBaselineAdLibPieces, - RundownPlaylists, - Rundowns, - Segments, + AdLibActions: new UiTriggersCollectionWrapper(AdLibActions), + AdLibPieces: new UiTriggersCollectionWrapper(AdLibPieces), + Parts: new UiTriggersCollectionWrapper(Parts), + RundownBaselineAdLibActions: new UiTriggersCollectionWrapper(RundownBaselineAdLibActions), + RundownBaselineAdLibPieces: new UiTriggersCollectionWrapper(RundownBaselineAdLibPieces), + RundownPlaylists: new UiTriggersCollectionWrapper(RundownPlaylists), + Rundowns: new UiTriggersCollectionWrapper(Rundowns), + Segments: new UiTriggersCollectionWrapper(Segments), hashSingleUseToken, doUserAction, - nonreactiveTracker: Tracker.nonreactive, + withComputation: async (computation, func) => { + return Tracker.withComputation(computation as Tracker.Computation | null, func) + }, - memoizedIsolatedAutorun, + memoizedIsolatedAutorun: async ( + computation: TriggerTrackerComputation | null, + fnc: (computation: TriggerTrackerComputation | null, ...args: TArgs) => Promise, + functionName: string, + ...params: TArgs + ): Promise => { + return memoizedIsolatedAutorunAsync( + computation as Tracker.Computation | null, + async (innerComputation, ...params2) => fnc(toTriggersComputation(innerComputation), ...params2), + functionName, + ...params + ) + }, - createContextForRundownPlaylistChain( + async createContextForRundownPlaylistChain( _studioId: StudioId, _filterChain: IBaseFilterLink[] - ): ReactivePlaylistActionContext | undefined { + ): Promise { // Server only throw new Error('Invalid filter combination') }, } + +export function toTriggersReactiveVar(reactiveVar: MeteorReactiveVar): TriggerReactiveVar { + return reactiveVar as any +} + +export function toTriggersComputation(computation: Tracker.Computation): TriggerTrackerComputation { + return computation as any +} diff --git a/packages/webui/src/client/ui/RundownView.tsx b/packages/webui/src/client/ui/RundownView.tsx index 13b332e431..8464b5b113 100644 --- a/packages/webui/src/client/ui/RundownView.tsx +++ b/packages/webui/src/client/ui/RundownView.tsx @@ -1370,7 +1370,7 @@ const RundownViewContent = translateWithTracker RundownPlaylistCollectionUtil.getRundownsOrdered(playlist), + (_playlistId: RundownPlaylistId) => RundownPlaylistCollectionUtil.getRundownsOrdered(playlist), 'playlist.getRundowns', playlistId ) diff --git a/packages/webui/src/client/ui/Settings/components/triggeredActions/TriggeredActionEntry.tsx b/packages/webui/src/client/ui/Settings/components/triggeredActions/TriggeredActionEntry.tsx index 8b1ec894b8..8ecf9d19a3 100644 --- a/packages/webui/src/client/ui/Settings/components/triggeredActions/TriggeredActionEntry.tsx +++ b/packages/webui/src/client/ui/Settings/components/triggeredActions/TriggeredActionEntry.tsx @@ -10,11 +10,15 @@ import { } from '@sofie-automation/blueprints-integration' import classNames from 'classnames' import { DBBlueprintTrigger } from '@sofie-automation/meteor-lib/dist/collections/TriggeredActions' -import { useTracker } from '../../../../lib/ReactMeteorData/ReactMeteorData' +import { useTracker, useTrackerAsync } from '../../../../lib/ReactMeteorData/ReactMeteorData' import { ActionEditor } from './actionEditors/ActionEditor' import { OutputLayers, SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { flatten, getRandomString } from '../../../../lib/tempLib' -import { createAction, isPreviewableAction } from '@sofie-automation/meteor-lib/dist/triggers/actionFactory' +import { + createAction, + isPreviewableAction, + PlainActionContext, +} from '@sofie-automation/meteor-lib/dist/triggers/actionFactory' import { PreviewContext } from './TriggeredActionsEditor' import { IWrappedAdLib } from '@sofie-automation/meteor-lib/dist/triggers/actionFilterChainCompilers' import { RundownUtils } from '../../../../lib/rundown' @@ -35,7 +39,7 @@ import { isHotkeyTrigger } from '@sofie-automation/meteor-lib/dist/triggers/trig import { getAllCurrentAndDeletedItemsFromOverrides, useOverrideOpHelper } from '../../util/OverrideOpHelper' import { TriggeredActions } from '../../../../collections' import { catchError } from '../../../../lib/lib' -import { UiTriggersContext } from '../../../../lib/triggers/triggersContext' +import { toTriggersComputation, UiTriggersContext } from '../../../../lib/triggers/triggersContext' import { last, literal } from '@sofie-automation/shared-lib/dist/lib/lib' interface IProps { @@ -179,27 +183,36 @@ export const TriggeredActionEntry: React.FC = React.memo(function Trigge [triggeredAction?.actionsWithOverrides] ) - const previewItems = useTracker( - () => { + const previewItems = useTrackerAsync( + async (computation) => { try { - if (resolvedActions && selected && sourceLayers) { - const executableActions = Object.values(resolvedActions).map((value) => - createAction(UiTriggersContext, value, sourceLayers) - ) - const ctx = previewContext - if (ctx && ctx.rundownPlaylist) { - return flatten( - executableActions.map((action) => (isPreviewableAction(action) ? action.preview(ctx as any) : [])) + if (!resolvedActions || !selected || !sourceLayers) return [] + + const triggerComputation = toTriggersComputation(computation) + + const executableActions = Object.values(resolvedActions).map((value) => + createAction(UiTriggersContext, value, sourceLayers) + ) + const ctx = previewContext + if (!ctx || !ctx.rundownPlaylist) return [] + + const actionCtx = ctx as PlainActionContext + + return flatten( + await Promise.all( + executableActions.map( + async (action): Promise => + isPreviewableAction(action) ? action.preview(actionCtx, triggerComputation) : [] ) - } - } + ) + ) } catch (e) { catchError('TriggeredActionEntry previewItems')(e) } - return [] as IWrappedAdLib[] + return [] }, [selected, resolvedActions, sourceLayers, previewContext], - [] as IWrappedAdLib[] + [] ) function getType(sourceLayerId: string | undefined): SourceLayerType { diff --git a/packages/webui/src/meteor/reactive-var.d.ts b/packages/webui/src/meteor/reactive-var.d.ts index 122ea35c0d..933f6fd32b 100644 --- a/packages/webui/src/meteor/reactive-var.d.ts +++ b/packages/webui/src/meteor/reactive-var.d.ts @@ -1,22 +1,25 @@ - var ReactiveVar: ReactiveVarStatic; - interface ReactiveVarStatic { - /** - * Constructor for a ReactiveVar, which represents a single reactive variable. - * @param initialValue The initial value to set. `equalsFunc` is ignored when setting the initial value. - * @param equalsFunc A function of two arguments, called on the old value and the new value whenever the ReactiveVar is set. If it returns true, no set is performed. If omitted, the default - * `equalsFunc` returns true if its arguments are `===` and are of type number, boolean, string, undefined, or null. - */ - new (initialValue: T, equalsFunc?: (oldValue: T, newValue: T) => boolean): ReactiveVar; - } - interface ReactiveVar { - /** - * Returns the current value of the ReactiveVar, establishing a reactive dependency. - */ - get(): T; - /** - * Sets the current value of the ReactiveVar, invalidating the Computations that called `get` if `newValue` is different from the old value. - */ - set(newValue: T): void; - } +import type { Tracker } from './tracker' - export { ReactiveVar } \ No newline at end of file +var ReactiveVar: ReactiveVarStatic +interface ReactiveVarStatic { + /** + * Constructor for a ReactiveVar, which represents a single reactive variable. + * @param initialValue The initial value to set. `equalsFunc` is ignored when setting the initial value. + * @param equalsFunc A function of two arguments, called on the old value and the new value whenever the ReactiveVar is set. If it returns true, no set is performed. If omitted, the default + * `equalsFunc` returns true if its arguments are `===` and are of type number, boolean, string, undefined, or null. + */ + new (initialValue: T, equalsFunc?: (oldValue: T, newValue: T) => boolean): ReactiveVar +} +interface ReactiveVar { + /** + * Returns the current value of the ReactiveVar, establishing a reactive dependency. + * @param fromComputation An optional computation declared to depend on `dependency` instead of the current computation. + */ + get(fromComputation?: Tracker.Computation): T + /** + * Sets the current value of the ReactiveVar, invalidating the Computations that called `get` if `newValue` is different from the old value. + */ + set(newValue: T): void +} + +export { ReactiveVar } diff --git a/packages/webui/src/meteor/reactive-var.js b/packages/webui/src/meteor/reactive-var.js index 0817f62e55..7552f36364 100644 --- a/packages/webui/src/meteor/reactive-var.js +++ b/packages/webui/src/meteor/reactive-var.js @@ -61,9 +61,9 @@ export const ReactiveVar = function (initialValue, equalsFunc) { * @summary Returns the current value of the ReactiveVar, establishing a reactive dependency. * @locus Client */ - ReactiveVar.prototype.get = function () { - if (Tracker.active) - this.dep.depend(); + ReactiveVar.prototype.get = function (computation) { + if (Tracker.active || computation) + this.dep.depend(computation); return this.curValue; }; diff --git a/packages/webui/src/meteor/tracker.d.ts b/packages/webui/src/meteor/tracker.d.ts index d210c0e408..da9f0229ff 100644 --- a/packages/webui/src/meteor/tracker.d.ts +++ b/packages/webui/src/meteor/tracker.d.ts @@ -1,129 +1,139 @@ - /** - * The namespace for Tracker-related methods. - */ - export namespace Tracker { - function Computation(): void; - /** - * A Computation object represents code that is repeatedly rerun - * in response to - * reactive data changes. Computations don't have return values; they just - * perform actions, such as rerendering a template on the screen. Computations - * are created using Tracker.autorun. Use stop to prevent further rerunning of a - * computation. - */ - interface Computation { - /** - * True during the initial run of the computation at the time `Tracker.autorun` is called, and false on subsequent reruns and at other times. - */ - firstRun: boolean; - /** - * Invalidates this computation so that it will be rerun. - */ - invalidate(): void; - /** - * True if this computation has been invalidated (and not yet rerun), or if it has been stopped. - */ - invalidated: boolean; - /** - * Registers `callback` to run when this computation is next invalidated, or runs it immediately if the computation is already invalidated. The callback is run exactly once and not upon - * future invalidations unless `onInvalidate` is called again after the computation becomes valid again. - * @param callback Function to be called on invalidation. Receives one argument, the computation that was invalidated. - */ - onInvalidate(callback: Function): void; - /** - * Registers `callback` to run when this computation is stopped, or runs it immediately if the computation is already stopped. The callback is run after any `onInvalidate` callbacks. - * @param callback Function to be called on stop. Receives one argument, the computation that was stopped. - */ - onStop(callback: Function): void; - /** - * Prevents this computation from rerunning. - */ - stop(): void; - /** - * True if this computation has been stopped. - */ - stopped: boolean; - } - /** - * The current computation, or `null` if there isn't one. The current computation is the `Tracker.Computation` object created by the innermost active call to - * `Tracker.autorun`, and it's the computation that gains dependencies when reactive data sources are accessed. - */ - var currentComputation: Computation; +/** + * The namespace for Tracker-related methods. + */ +export namespace Tracker { + function Computation(): void + /** + * A Computation object represents code that is repeatedly rerun + * in response to + * reactive data changes. Computations don't have return values; they just + * perform actions, such as rerendering a template on the screen. Computations + * are created using Tracker.autorun. Use stop to prevent further rerunning of a + * computation. + */ + interface Computation { + /** + * True during the initial run of the computation at the time `Tracker.autorun` is called, and false on subsequent reruns and at other times. + */ + firstRun: boolean + /** + * Forces autorun blocks to be executed in synchronous-looking order by storing the value autorun promise thus making it awaitable. + */ + firstRunPromise: Promise + /** + * Invalidates this computation so that it will be rerun. + */ + invalidate(): void + /** + * True if this computation has been invalidated (and not yet rerun), or if it has been stopped. + */ + invalidated: boolean + /** + * Registers `callback` to run when this computation is next invalidated, or runs it immediately if the computation is already invalidated. The callback is run exactly once and not upon + * future invalidations unless `onInvalidate` is called again after the computation becomes valid again. + * @param callback Function to be called on invalidation. Receives one argument, the computation that was invalidated. + */ + onInvalidate(callback: Function): void + /** + * Registers `callback` to run when this computation is stopped, or runs it immediately if the computation is already stopped. The callback is run after any `onInvalidate` callbacks. + * @param callback Function to be called on stop. Receives one argument, the computation that was stopped. + */ + onStop(callback: Function): void + /** + * Prevents this computation from rerunning. + */ + stop(): void + /** + * True if this computation has been stopped. + */ + stopped: boolean + } + /** + * The current computation, or `null` if there isn't one. The current computation is the `Tracker.Computation` object created by the innermost active call to + * `Tracker.autorun`, and it's the computation that gains dependencies when reactive data sources are accessed. + */ + var currentComputation: Computation | null - var Dependency: DependencyStatic; - /** - * A Dependency represents an atomic unit of reactive data that a - * computation might depend on. Reactive data sources such as Session or - * Minimongo internally create different Dependency objects for different - * pieces of data, each of which may be depended on by multiple computations. - * When the data changes, the computations are invalidated. - */ - interface DependencyStatic { - new (): Dependency; - } - interface Dependency { - /** - * Invalidate all dependent computations immediately and remove them as dependents. - */ - changed(): void; - /** - * Declares that the current computation (or `fromComputation` if given) depends on `dependency`. The computation will be invalidated the next time `dependency` changes. - * If there is no current computation and `depend()` is called with no arguments, it does nothing and returns false. - * Returns true if the computation is a new dependent of `dependency` rather than an existing one. - * @param fromComputation An optional computation declared to depend on `dependency` instead of the current computation. - */ - depend(fromComputation?: Computation): boolean; - /** - * True if this Dependency has one or more dependent Computations, which would be invalidated if this Dependency were to change. - */ - hasDependents(): boolean; - } + var Dependency: DependencyStatic + /** + * A Dependency represents an atomic unit of reactive data that a + * computation might depend on. Reactive data sources such as Session or + * Minimongo internally create different Dependency objects for different + * pieces of data, each of which may be depended on by multiple computations. + * When the data changes, the computations are invalidated. + */ + interface DependencyStatic { + new (): Dependency + } + interface Dependency { + /** + * Invalidate all dependent computations immediately and remove them as dependents. + */ + changed(): void + /** + * Declares that the current computation (or `fromComputation` if given) depends on `dependency`. The computation will be invalidated the next time `dependency` changes. + * If there is no current computation and `depend()` is called with no arguments, it does nothing and returns false. + * Returns true if the computation is a new dependent of `dependency` rather than an existing one. + * @param fromComputation An optional computation declared to depend on `dependency` instead of the current computation. + */ + depend(fromComputation?: Computation | null): boolean + /** + * True if this Dependency has one or more dependent Computations, which would be invalidated if this Dependency were to change. + */ + hasDependents(): boolean + } - /** - * True if there is a current computation, meaning that dependencies on reactive data sources will be tracked and potentially cause the current computation to be rerun. - */ - var active: boolean; + /** + * True if there is a current computation, meaning that dependencies on reactive data sources will be tracked and potentially cause the current computation to be rerun. + */ + var active: boolean - /** - * Schedules a function to be called during the next flush, or later in the current flush if one is in progress, after all invalidated computations have been rerun. The function will be run - * once and not on subsequent flushes unless `afterFlush` is called again. - * @param callback A function to call at flush time. - */ - function afterFlush(callback: Function): void; + /** + * Schedules a function to be called during the next flush, or later in the current flush if one is in progress, after all invalidated computations have been rerun. The function will be run + * once and not on subsequent flushes unless `afterFlush` is called again. + * @param callback A function to call at flush time. + */ + function afterFlush(callback: Function): void - /** - * Run a function now and rerun it later whenever its dependencies - * change. Returns a Computation object that can be used to stop or observe the - * rerunning. - * @param runFunc The function to run. It receives one argument: the Computation object that will be returned. - */ - function autorun( - runFunc: (computation: Computation) => void, - options?: { - /** - * The function to run when an error - * happens in the Computation. The only argument it receives is the Error - * thrown. Defaults to the error being logged to the console. - */ - onError?: Function | undefined; - }, - ): Computation; + /** + * Run a function now and rerun it later whenever its dependencies + * change. Returns a Computation object that can be used to stop or observe the + * rerunning. + * @param runFunc The function to run. It receives one argument: the Computation object that will be returned. + */ + function autorun( + runFunc: (computation: Computation) => void | Promise, + options?: { + /** + * The function to run when an error + * happens in the Computation. The only argument it receives is the Error + * thrown. Defaults to the error being logged to the console. + */ + onError?: Function | undefined + } + ): Computation - /** - * Process all reactive updates immediately and ensure that all invalidated computations are rerun. - */ - function flush(): void; + /** + * @summary Helper function to make the tracker work with promises. + * @param computation Computation that tracked + * @param func async function that needs to be called and be reactive + */ + function withComputation(computation: Computation | null, func: () => Promise): Promise - /** - * Run a function without tracking dependencies. - * @param func A function to call immediately. - */ - function nonreactive(func: () => T): T; + /** + * Process all reactive updates immediately and ensure that all invalidated computations are rerun. + */ + function flush(): void - /** - * Registers a new `onInvalidate` callback on the current computation (which must exist), to be called immediately when the current computation is invalidated or stopped. - * @param callback A callback function that will be invoked as `func(c)`, where `c` is the computation on which the callback is registered. - */ - function onInvalidate(callback: Function): void; - } + /** + * Run a function without tracking dependencies. + * @param func A function to call immediately. + */ + function nonreactive(func: () => T): T + /** + * Registers a new `onInvalidate` callback on the current computation (which must exist), to be called immediately when the current computation is invalidated or stopped. + * @param callback A callback function that will be invoked as `func(c)`, where `c` is the computation on which the callback is registered. + */ + function onInvalidate(callback: Function): void +} diff --git a/packages/webui/src/meteor/tracker.js b/packages/webui/src/meteor/tracker.js index d860e19087..632352b1a5 100644 --- a/packages/webui/src/meteor/tracker.js +++ b/packages/webui/src/meteor/tracker.js @@ -1,4 +1,4 @@ -// https://github.com/meteor/meteor/blob/73fd519de6eef8e116d813fb457c8442db9d1cdd/packages/tracker/tracker.js +// https://github.com/meteor/meteor/blob/0afa7df1fa4146f1f5dd26d867b32c19b7e8d4ad/packages/tracker/tracker.js ///////////////////////////////////////////////////// // Package docs at http://docs.meteor.com/#tracker // @@ -6,8 +6,6 @@ import { Meteor } from './meteor' -export const a = 'a' - /** * @namespace Tracker * @summary The namespace for Tracker-related methods. @@ -38,11 +36,6 @@ Tracker.active = false */ Tracker.currentComputation = null -function setCurrentComputation(c) { - Tracker.currentComputation = c - Tracker.active = !!c -} - function _debugFunc() { // We want this code to work without Meteor, and also without // "console" (which is technically non-standard and may be missing @@ -189,6 +182,16 @@ Tracker.Computation = class Computation { this._onError = onError this._recomputing = false + /** + * @summary Forces autorun blocks to be executed in synchronous-looking order by storing the value autorun promise thus making it awaitable. + * @locus Client + * @memberOf Tracker.Computation + * @instance + * @name firstRunPromise + * @returns {Promise} + */ + this.firstRunPromise = undefined + var errored = true try { this._compute() @@ -199,6 +202,20 @@ Tracker.Computation = class Computation { } } + /** + * Resolves the firstRunPromise with the result of the autorun function. + * @param {*} onResolved + * @param {*} onRejected + * @returns{Promise { + return this._func(this) + }) + // We'll store the firstRunPromise on the computation so it can be awaited by the callers, but only + // during the first run. We don't want things to get mixed up. + if (this.firstRun) { + this.firstRunPromise = Promise.resolve(firstRunPromise) + } } finally { - setCurrentComputation(previous) inCompute = previousInCompute } } @@ -368,15 +392,15 @@ Tracker.Dependency = class Dependency { // if there is no currentComputation. /** - * @summary Declares that the current computation (or `fromComputation` if given) depends on `dependency`. The computation will be invalidated the next time `dependency` changes. - - If there is no current computation and `depend()` is called with no arguments, it does nothing and returns false. - - Returns true if the computation is a new dependent of `dependency` rather than an existing one. - * @locus Client - * @param {Tracker.Computation} [fromComputation] An optional computation declared to depend on `dependency` instead of the current computation. - * @returns {Boolean} - */ + * @summary Declares that the current computation (or `fromComputation` if given) depends on `dependency`. The computation will be invalidated the next time `dependency` changes. + + If there is no current computation and `depend()` is called with no arguments, it does nothing and returns false. + + Returns true if the computation is a new dependent of `dependency` rather than an existing one. + * @locus Client + * @param {Tracker.Computation} [fromComputation] An optional computation declared to depend on `dependency` instead of the current computation. + * @returns {Boolean} + */ depend(computation) { if (!computation) { if (!Tracker.active) return false @@ -542,11 +566,9 @@ Tracker._runFlush = function (options) { * thrown. Defaults to the error being logged to the console. * @returns {Tracker.Computation} */ -Tracker.autorun = function (f, options) { +Tracker.autorun = function (f, options = {}) { if (typeof f !== 'function') throw new Error('Tracker.autorun requires a function argument') - options = options || {} - constructingComputation = true var c = new Tracker.Computation(f, Tracker.currentComputation, options.onError) @@ -571,12 +593,25 @@ Tracker.autorun = function (f, options) { * @param {Function} func A function to call immediately. */ Tracker.nonreactive = function (f) { - var previous = Tracker.currentComputation - setCurrentComputation(null) + return Tracker.withComputation(null, f) +} + +/** + * @summary Helper function to make the tracker work with promises. + * @param computation Computation that tracked + * @param func async function that needs to be called and be reactive + */ +Tracker.withComputation = function (computation, f) { + var previousComputation = Tracker.currentComputation + + Tracker.currentComputation = computation + Tracker.active = !!computation + try { return f() } finally { - setCurrentComputation(previous) + Tracker.currentComputation = previousComputation + Tracker.active = !!previousComputation } }