UNPKG

@empirica/core

Version:
1 lines 92.1 kB
{"version":3,"sources":["../src/admin/user.ts","../src/shared/attributes.ts","../src/utils/console.ts","../src/shared/tajriba_connection.ts","../src/utils/object.ts","../src/admin/attributes.ts","../src/admin/connection.ts","../src/admin/observables.ts","../src/admin/events.ts","../src/admin/participants.ts","../src/admin/promises.ts","../src/admin/scopes.ts","../src/shared/scopes.ts","../src/admin/subscriptions.ts","../src/admin/transitions.ts"],"sourcesContent":["export { Attribute } from \"../shared/attributes\";\nexport type { AttributeOptions } from \"../shared/attributes\";\nexport type { ScopeUpdate } from \"../shared/scopes\";\nexport { TajribaConnection } from \"../shared/tajriba_connection\";\nexport { Attributes } from \"./attributes\";\nexport { AdminConnection } from \"./connection\";\nexport { EventContext, ListenersCollector, TajribaEvent } from \"./events\";\nexport type { Subscriber } from \"./events\";\nexport { participantsSub } from \"./participants\";\nexport type { Participant } from \"./participants\";\nexport { Scope, Scopes } from \"./scopes\";\nexport { Subscriptions } from \"./subscriptions\";\nexport type { ScopeSubscriptionInput, Subs } from \"./subscriptions\";\nexport { transitionsSub } from \"./transitions\";\nexport type { Transition } from \"./transitions\";\n","import { SetAttributeInput } from \"@empirica/tajriba\";\nimport { BehaviorSubject, Observable } from \"rxjs\";\nimport { error, trace } from \"../utils/console\";\nimport { JsonValue } from \"../utils/json\";\n\nexport interface AttributeChange {\n /** deleted is true with the attribute was deleted. */\n deleted?: boolean;\n /** deletedAt is the time when the Attribute was deleted. int64 Date + Time\n * value given in Epoch with ns precision */\n deletedAt?: number;\n /** createdAt is the time the Attribute was created. int64 Date + Time\n * value given in Epoch with ns precision */\n createdAt?: string;\n /** id is the identifier for the Attribute. */\n id: string;\n /** index is the index of the attribute if the value is a vector. */\n index?: number | null;\n /** isNew is true if the Attribute was just created. */\n isNew?: boolean;\n /** key is the attribute key being updated. */\n key: string;\n /** nodeID is the identifier for the Attribute's Node. */\n nodeID?: string;\n /** node is the Attribute's Node. */\n node?: {\n __typename: \"Scope\";\n id: string;\n kind?: string;\n name?: string;\n };\n /** value is the value of the updated attribute. */\n val?: string | null;\n /** vector indicates whether the value is a vector. */\n vector: boolean;\n /** version is the version number of this Attribute, starting at 1. */\n version: number;\n}\n\nexport interface AttributeUpdate {\n attribute: AttributeChange;\n removed: boolean;\n}\n\nexport class Attributes {\n protected attrs = new Map<string, Map<string, Attribute>>();\n protected updates = new Map<string, Map<string, AttributeChange | boolean>>();\n\n constructor(\n attributesObs: Observable<AttributeUpdate>,\n donesObs: Observable<string[]>,\n readonly setAttributes: (input: SetAttributeInput[]) => Promise<unknown>\n ) {\n attributesObs.subscribe({\n next: ({ attribute, removed }) => {\n this.update(attribute, removed);\n },\n });\n\n donesObs.subscribe({\n next: (scopeIDs) => {\n this.next(scopeIDs);\n },\n });\n }\n\n attribute(scopeID: string, key: string): Attribute {\n let scopeMap = this.attrs.get(scopeID);\n if (!scopeMap) {\n scopeMap = new Map();\n this.attrs.set(scopeID, scopeMap);\n }\n\n let attr = scopeMap.get(key);\n if (!attr) {\n attr = new Attribute(this.setAttributes, scopeID, key);\n scopeMap.set(key, attr);\n }\n\n return attr;\n }\n\n attributes(scopeID: string): Attribute[] {\n let scopeMap = this.attrs.get(scopeID);\n if (!scopeMap) {\n scopeMap = new Map();\n this.attrs.set(scopeID, scopeMap);\n }\n\n return Array.from(scopeMap.values());\n }\n\n attributePeek(scopeID: string, key: string): Attribute | undefined {\n let scopeUpdateMap = this.updates.get(scopeID);\n if (scopeUpdateMap) {\n const updated = scopeUpdateMap.get(key);\n if (updated) {\n if (typeof updated === \"boolean\") {\n return;\n } else {\n if (!updated.val) {\n return;\n } else {\n const attr = new Attribute(this.setAttributes, scopeID, key);\n attr._update(updated);\n return attr;\n }\n }\n }\n }\n\n let scopeMap = this.attrs.get(scopeID);\n if (!scopeMap) {\n return;\n }\n\n let attr = scopeMap.get(key);\n if (!attr) {\n return;\n }\n\n if (attr.value === undefined) {\n return;\n }\n\n return attr;\n }\n\n nextAttributeValue(scopeID: string, key: string): JsonValue | undefined {\n const attr = this.attributePeek(scopeID, key);\n if (!attr) {\n return;\n }\n\n return attr.value;\n }\n\n private update(attr: AttributeChange, removed: boolean) {\n let nodeID = attr.nodeID;\n if (!nodeID) {\n if (!attr.node?.id) {\n error(`new attribute without node ID`);\n return;\n }\n nodeID = attr.node.id;\n }\n\n let scopeMap = this.updates.get(nodeID);\n if (!scopeMap) {\n scopeMap = new Map();\n this.updates.set(nodeID, scopeMap);\n }\n\n if (removed) {\n scopeMap.set(attr.key, true);\n } else {\n let key = attr.key;\n if (attr.index !== undefined && attr.index !== null) {\n key = `${key}[${attr.index}]`;\n }\n scopeMap.set(key, attr);\n }\n }\n\n scopeWasUpdated(scopeID?: string): boolean {\n if (!scopeID) {\n return false;\n }\n\n return this.updates.has(scopeID);\n }\n\n protected next(scopeIDs: string[]) {\n for (const [scopeID, attrs] of this.updates) {\n if (!scopeIDs.includes(scopeID)) {\n continue;\n }\n\n let scopeMap = this.attrs.get(scopeID);\n\n if (!scopeMap) {\n scopeMap = new Map();\n this.attrs.set(scopeID, scopeMap);\n }\n\n for (const [key, attrOrDel] of attrs) {\n if (typeof attrOrDel === \"boolean\") {\n let attr = scopeMap.get(key);\n if (attr) {\n attr._update(undefined);\n }\n } else {\n let attr = scopeMap.get(attrOrDel.key);\n if (!attr) {\n attr = new Attribute(this.setAttributes, scopeID, attrOrDel.key);\n scopeMap.set(attrOrDel.key, attr);\n }\n\n attr._update(attrOrDel);\n }\n }\n }\n\n for (const scopeID of scopeIDs) {\n this.updates.delete(scopeID);\n }\n }\n}\n\nexport interface AttributeOptions {\n /**\n * Private indicates the attribute will not be visible to other Participants.\n */\n private: boolean;\n /**\n * Protected indicates the attribute will not be updatable by other\n * Participants.\n */\n protected: boolean;\n /** Immutable creates an Attribute that cannot be updated. */\n immutable: boolean;\n /** ephemeral indicates the Attribute should not be persisted. Ephemeral\n * Attributes are not stored in the database and are only synced to the\n * connected clients. An ephemeral Attribute cannot become non-ephemeral and\n * vice versa. */\n ephemeral: boolean;\n /**\n * Index, only used if the Attribute is a vector, indicates which index to\n * update the value at.\n */\n index: number | null;\n /**\n * Append, only used if the Attribute is a vector, indicates to append the\n * attribute to the vector.\n */\n append: boolean | null;\n}\n\nexport class Attribute {\n private attr?: AttributeChange;\n private attrs?: Attribute[];\n\n private val = new BehaviorSubject<JsonValue | undefined>(undefined);\n private serVal?: string;\n\n constructor(\n private setAttributes: (input: SetAttributeInput[]) => Promise<unknown>,\n readonly scopeID: string,\n readonly key: string\n ) {}\n\n get id() {\n return this.attr?.id;\n }\n\n get createdAt() {\n return this.attr ? new Date(this.attr!.createdAt!) : null;\n }\n\n get obs(): Observable<JsonValue | undefined> {\n return this.val;\n }\n\n get value() {\n return this.val.getValue();\n }\n\n get nodeID() {\n return this.scopeID;\n }\n\n // items returns the attribute changes for the current attribute, if it is a\n // vector. Otherwise it returns null;\n get items() {\n if (!this.attrs) {\n return null;\n }\n\n return this.attrs;\n }\n\n set(value: JsonValue, ao?: Partial<AttributeOptions>) {\n const attrProps = this._prepSet(value, ao);\n if (!attrProps) {\n return;\n }\n\n this.setAttributes([attrProps]);\n trace(`SET ${this.key} = ${value} (${this.scopeID})`);\n }\n\n _prepSet(\n value: JsonValue,\n ao?: Partial<AttributeOptions>,\n item?: boolean\n ): SetAttributeInput | undefined {\n if (ao?.append !== undefined && ao!.index !== undefined) {\n error(`cannot set both append and index`);\n\n throw new Error(`cannot set both append and index`);\n }\n\n const serVal = JSON.stringify(value);\n\n if (!item && (ao?.index !== undefined || ao?.append)) {\n let index = ao!.index || 0;\n if (ao?.append) {\n index = this.attrs?.length || 0;\n }\n\n if (!this.attrs) {\n this.attrs = [];\n }\n\n // if (index + 1 > (this.attrs?.length || 0)) {\n // this.attrs.length = index! + 1;\n // }\n\n if (!this.attrs[index]) {\n this.attrs[index] = new Attribute(\n this.setAttributes,\n this.scopeID,\n this.key\n );\n } else {\n const existing = this.attrs[index];\n if (existing && existing.serVal === serVal) {\n return;\n }\n }\n\n this.attrs![index]!._prepSet(value, ao, true);\n const v = this._recalcVectorVal();\n this.val.next(v);\n } else {\n if (this.serVal === serVal) {\n return;\n }\n\n this.val.next(value);\n }\n\n this.serVal = serVal;\n\n const attrProps: SetAttributeInput = {\n key: this.key,\n nodeID: this.scopeID,\n val: serVal,\n };\n\n if (ao) {\n // TODO Fix this. Should check if compatible with existing attribute and\n // only set fields set on ao.\n attrProps.private = ao.private;\n attrProps.protected = ao.protected;\n attrProps.immutable = ao.immutable;\n attrProps.ephemeral = ao.ephemeral;\n attrProps.append = ao.append;\n attrProps.index = ao.index;\n }\n\n return attrProps;\n }\n\n private _recalcVectorVal(): JsonValue {\n return this.attrs!.map((a) =>\n !a || a.val == undefined ? null : a.value || null\n );\n }\n\n // internal only\n _update(attr?: AttributeChange, item?: boolean) {\n if (attr && this.attr && this.attr.id === attr.id) {\n return;\n }\n\n if (attr && attr.vector && !item) {\n // TODO check if is vector\n\n if (attr.index === undefined) {\n error(`vector attribute missing index`);\n return;\n }\n\n if (this.attrs == undefined) {\n this.attrs = [];\n }\n\n while (this.attrs.length < attr.index! + 1) {\n const newAttr = new Attribute(\n this.setAttributes,\n this.scopeID,\n this.key\n );\n this.attrs.push(newAttr);\n }\n\n const newAttr = new Attribute(this.setAttributes, this.scopeID, this.key);\n newAttr._update(attr, true);\n this.attrs[attr.index!] = newAttr;\n const value = this._recalcVectorVal();\n this.val.next(value);\n\n return;\n }\n\n this.attr = attr;\n this.serVal = attr?.val === undefined || attr?.val === null ? \"\" : attr.val;\n let value: JsonValue | undefined = undefined;\n if (this.attr?.val) {\n value = JSON.parse(this.attr.val);\n }\n this.val.next(value);\n }\n}\n","/* c8 ignore start */\n\nconst isBrowser =\n typeof window !== \"undefined\" && typeof window.document !== \"undefined\";\n\nenum Color {\n Bold = 1,\n\n Black = 30,\n Red,\n Green,\n Yellow,\n Blue,\n Magenta,\n Cyan,\n White,\n\n DarkGray = 90,\n}\n\nexport type LogLine = { level: string; args: any[] };\nexport class LogsMock {\n public logs: LogLine[] = [];\n\n log(line: LogLine) {\n this.logs.push(line);\n }\n\n clear() {\n this.logs = [];\n }\n}\n\nlet logsMock: LogsMock | undefined;\nexport function captureLogs(cb: () => void): LogLine[] {\n const lm = mockLogging();\n cb();\n const ret = lm.logs;\n stopMockLogging();\n\n return ret;\n}\n\nexport async function captureLogsAsync(\n cb: () => Promise<void>\n): Promise<LogLine[]> {\n const lm = mockLogging();\n await cb();\n const ret = lm.logs;\n stopMockLogging();\n\n return ret;\n}\n\nexport function mockLogging() {\n if (!logsMock) {\n logsMock = new LogsMock();\n }\n\n return logsMock;\n}\n\nexport function stopMockLogging() {\n logsMock = undefined;\n}\n\nconst colorHex = {\n [Color.Bold]: \"font-weight: bold\",\n [Color.Black]: \"color: #000000\",\n [Color.Red]: \"color: #cc0000\",\n [Color.Green]: \"color: #4e9a06\",\n [Color.Yellow]: \"color: #c4a000\",\n [Color.Blue]: \"color: #729fcf\",\n [Color.Magenta]: \"color: #75507b\",\n [Color.Cyan]: \"color: #06989a\",\n [Color.White]: \"color: #d3d7cf\",\n [Color.DarkGray]: \"color: #555753\",\n};\n\nexport const levels: { [key: string]: number } = {\n trace: 0,\n debug: 1,\n log: 2,\n info: 2,\n warn: 3,\n error: 4,\n};\n\nconst reversLevels: { [key: number]: string } = {};\nfor (const key in levels) {\n reversLevels[levels[key]!] = key;\n}\n\nlet currentLevel = 2;\n\nexport function setLogLevel(level: keyof typeof levels) {\n const lvl = levels[level];\n if (lvl === undefined) {\n return;\n }\n\n currentLevel = lvl;\n}\n\nfunction formatConsoleDate(date: Date, level: string[]) {\n var hour = date.getHours();\n var minutes = date.getMinutes();\n var seconds = date.getSeconds();\n var milliseconds = date.getMilliseconds();\n\n const str =\n (hour < 10 ? \"0\" + hour : hour) +\n \":\" +\n (minutes < 10 ? \"0\" + minutes : minutes) +\n \":\" +\n (seconds < 10 ? \"0\" + seconds : seconds) +\n \".\" +\n (\"00\" + milliseconds).slice(-3);\n\n if (isBrowser) {\n const ts = colorize(str, Color.DarkGray).concat(level);\n return [ts[0] + \" \" + level[0], ts[1], level[1]];\n }\n\n return colorize(str, Color.DarkGray).concat(level);\n}\n\nconst createLogger = (lvl: number, level: string[]) => {\n return (...args: any[]) => {\n if (lvl < currentLevel) {\n return;\n }\n\n if (logsMock) {\n logsMock.log({ level: reversLevels[lvl]!, args: args });\n\n return;\n }\n\n if (args.length === 1) {\n switch (typeof args[0]) {\n case \"string\":\n for (const line of args[0].split(\"\\n\")) {\n console.log(...formatConsoleDate(new Date(), level).concat(line));\n }\n return;\n\n case \"object\":\n if (args[0] instanceof Error) {\n const error = args[0] as Error;\n const prettyErr =\n error.name +\n \": \" +\n error.message.replace(new RegExp(`^${error.name}[: ]*`), \"\") +\n \"\\n\" +\n (error.stack || \"\")\n .split(\"\\n\")\n .map((line) => line.trim())\n .map((line) => {\n if (line.startsWith(error.name + \": \" + error.message))\n return null;\n\n if (line.startsWith(\"at\")) {\n return \" \" + line;\n }\n\n return line;\n })\n .filter(Boolean)\n .join(\"\\n\");\n\n for (const line of prettyErr.split(\"\\n\")) {\n console.log(...formatConsoleDate(new Date(), level).concat(line));\n }\n\n return;\n }\n }\n }\n\n console.log(...formatConsoleDate(new Date(), level).concat(args));\n };\n};\n\nfunction colorize(s: string, ...cc: Color[]): string[] {\n if (isBrowser) {\n const attr = [];\n for (const c of cc) {\n attr.push(colorHex[c]);\n }\n\n return [`%c${s}`, attr.join(\"; \")];\n }\n\n let out = \"\";\n for (const c of cc) {\n out += `\\x1b[${c}m`;\n }\n out += `${s}\\x1b[0m`;\n\n return [out];\n}\n\nexport const trace = createLogger(0, colorize(\"TRC\", Color.Magenta));\nexport const debug = createLogger(1, colorize(\"DBG\", Color.Yellow));\nexport const log = createLogger(2, colorize(\"LOG\", Color.Yellow));\nexport const info = createLogger(2, colorize(\"INF\", Color.Green));\nexport const warn = createLogger(3, colorize(\"WRN\", Color.Cyan));\nexport const error = createLogger(4, colorize(\"ERR\", Color.Red, Color.Bold));\n\n// export {\n// trace,\n// debug,\n// log,\n// info,\n// warn,\n// error,\n// };\n\n// export function warn(...args: string[]) {}\n","import { ParticipantIdent, Tajriba } from \"@empirica/tajriba\";\nimport { BehaviorSubject } from \"rxjs\";\nimport { error } from \"../utils/console\";\nimport { bs } from \"../utils/object\";\n\nexport const ErrNotConnected = new Error(\"not connected\");\n\nexport class TajribaConnection {\n readonly tajriba: Tajriba;\n private _connected = bs(false);\n private _connecting: BehaviorSubject<boolean> = bs(true);\n private _stopped = bs(false);\n\n constructor(private url: string) {\n this.tajriba = Tajriba.connect(this.url);\n this._connected.next(this.tajriba.connected);\n\n this.tajriba.on(\"connected\", () => {\n this._connected.next(true);\n this._connecting.next(false);\n });\n\n this.tajriba.on(\"disconnected\", () => {\n if (this._connected.getValue()) {\n this._connected.next(false);\n }\n if (!this._connecting.getValue()) {\n this._connecting.next(true);\n }\n });\n\n this.tajriba.on(\"error\", (err) => {\n error(\"connection error\", err);\n });\n }\n\n get connecting() {\n return this._connecting;\n }\n\n get connected() {\n return this._connected;\n }\n\n get stopped() {\n return this._stopped;\n }\n\n async sessionParticipant(token: string, pident: ParticipantIdent) {\n if (!this._connected.getValue()) {\n throw ErrNotConnected;\n }\n\n return await this.tajriba.sessionParticipant(token, pident);\n }\n\n async sessionAdmin(token: string) {\n if (!this._connected.getValue()) {\n throw ErrNotConnected;\n }\n\n return await this.tajriba.sessionAdmin(token);\n }\n\n stop() {\n if (this._stopped.getValue()) {\n return;\n }\n\n if (this.tajriba) {\n this.tajriba.removeAllListeners(\"connected\");\n this.tajriba.removeAllListeners(\"disconnected\");\n this.tajriba.stop();\n }\n\n this._connecting.next(false);\n this._connected.next(false);\n this._stopped.next(true);\n }\n}\n","/* c8 ignore start */\n\nimport { BehaviorSubject } from \"rxjs\";\n\nexport function bs<T>(init: T) {\n return new BehaviorSubject<T>(init);\n}\n\nexport function bsu<T>(init: T | undefined = undefined) {\n return new BehaviorSubject<T | undefined>(init);\n}\n\nexport function deepEqual(obj1: any, obj2: any) {\n if (obj1 === obj2)\n // it's just the same object. No need to compare.\n return true;\n\n if (isPrimitive(obj1) && isPrimitive(obj2))\n // compare primitives\n return obj1 === obj2;\n\n if (Object.keys(obj1).length !== Object.keys(obj2).length) return false;\n\n // compare objects with same number of keys\n for (let key in obj1) {\n if (!(key in obj2)) return false; //other object doesn't have this prop\n if (!deepEqual(obj1[key], obj2[key])) return false;\n }\n\n return true;\n}\n\n//check if value is primitive\nfunction isPrimitive(obj: any) {\n return obj !== Object(obj);\n}\n","import { Observable, ReplaySubject } from \"rxjs\";\nimport {\n Attribute,\n AttributeChange,\n Attributes as SharedAttributes,\n} from \"../shared/attributes\";\nimport { warn } from \"../utils/console\";\n\nexport type AttributeMsg = {\n attribute?: Attribute;\n done: boolean;\n};\n\nexport class Attributes extends SharedAttributes {\n protected attrsByKind = new Map<\n string,\n Map<string, Map<string, Attribute>>\n >();\n private attribSubs = new Map<\n string,\n Map<string, ReplaySubject<AttributeMsg>>\n >();\n\n subscribeAttribute(kind: string, key: string): Observable<AttributeMsg> {\n if (!this.attribSubs.has(kind)) {\n this.attribSubs.set(kind, new Map<string, ReplaySubject<AttributeMsg>>());\n }\n\n const keyMap = this.attribSubs.get(kind)!;\n let sub = keyMap.get(key);\n if (!sub) {\n sub = new ReplaySubject<AttributeMsg>();\n keyMap.set(key, sub);\n\n const attrByScopeID = this.attrsByKind.get(kind);\n\n setTimeout(() => {\n if (!attrByScopeID) {\n sub!.next({ done: true });\n return;\n }\n\n let attrs = [];\n for (const [_, attrByKey] of attrByScopeID?.entries()) {\n for (const [_, attr] of attrByKey) {\n if (attr.key === key) {\n attrs.push(attr);\n }\n }\n }\n\n if (attrs.length > 0) {\n let count = 0;\n for (const attr of attrs) {\n count++;\n sub!.next({ attribute: attr, done: count == attrs.length });\n }\n } else {\n sub!.next({ done: true });\n }\n }, 0);\n }\n\n return sub!;\n }\n\n protected next(scopeIDs: string[]) {\n const byKind = new Map<string, AttributeChange[]>();\n\n for (const [scopeID, attrs] of this.updates) {\n if (!scopeIDs.includes(scopeID)) {\n continue;\n }\n\n for (const [_, attr] of attrs) {\n if (typeof attr === \"boolean\") {\n continue;\n }\n\n const kind = attr.node?.kind;\n if (kind) {\n let kindAttrs = byKind.get(kind);\n if (!kindAttrs) {\n kindAttrs = [];\n byKind.set(kind, kindAttrs);\n }\n\n kindAttrs.push(attr);\n }\n }\n }\n\n const updates: [string, string, AttributeChange][] = [];\n for (const [kind, attrs] of byKind) {\n for (const attr of attrs) {\n // This is very difficult to reproduce in tests since this.updates\n // cannot contain an AttributeChange that would satisfy this.\n /* c8 ignore next 4 */\n if (!attr.nodeID && !attr.node?.id) {\n warn(`found attribute change without node ID`);\n continue;\n }\n\n if (!scopeIDs.includes(attr.nodeID || attr.node!.id)) {\n continue;\n }\n\n updates.push([kind, attr.key, attr]);\n }\n }\n\n super.next(scopeIDs);\n\n for (const [kind, key, attrChange] of updates) {\n // Forcing nodeID because we already tested it above.\n const nodeID = attrChange.nodeID || attrChange.node!.id;\n\n if (!scopeIDs.includes(nodeID)) {\n continue;\n }\n\n const attr = this.attrs.get(nodeID)!.get(key)!;\n const sub = this.attribSubs.get(kind)?.get(key);\n if (sub) {\n sub.next({ attribute: attr, done: true });\n } else {\n let kAttrs = this.attrsByKind.get(kind);\n if (!kAttrs) {\n kAttrs = new Map<string, Map<string, Attribute>>();\n this.attrsByKind.set(kind, kAttrs);\n }\n\n let kkAttrs = kAttrs!.get(nodeID);\n if (!kkAttrs) {\n kkAttrs = new Map<string, Attribute>();\n kAttrs!.set(nodeID, kkAttrs);\n }\n\n kkAttrs.set(key, attr);\n }\n }\n }\n}\n","import { TajribaAdmin } from \"@empirica/tajriba\";\nimport { BehaviorSubject, merge, SubscriptionLike } from \"rxjs\";\nimport {\n ErrNotConnected,\n TajribaConnection,\n} from \"../shared/tajriba_connection\";\nimport { bs, bsu } from \"../utils/object\";\nimport { subscribeAsync } from \"./observables\";\nimport { error } from \"../utils/console\";\n\nexport class AdminConnection {\n private _tajriba = bsu<TajribaAdmin>();\n private _connected = bs(false);\n private _connecting = bs(false);\n private _stopped = bs(false);\n private sub: SubscriptionLike;\n\n constructor(\n taj: TajribaConnection,\n tokens: BehaviorSubject<string | null | undefined>,\n private resetToken: () => void\n ) {\n let token: string | null | undefined;\n let connected = false;\n\n this.sub = subscribeAsync(\n merge(taj.connected, tokens),\n async (tokenOrConnected) => {\n if (typeof tokenOrConnected === \"boolean\") {\n connected = tokenOrConnected;\n } else {\n token = tokenOrConnected;\n }\n\n if (!token || !connected) {\n return;\n }\n\n if (this._connected.getValue()) {\n return;\n }\n\n this._connecting.next(true);\n\n try {\n const tajAdmin = await taj.sessionAdmin(token);\n\n this._tajriba.next(tajAdmin);\n this._connected.next(true);\n\n tajAdmin.on(\"connected\", () => {\n if (!this._connected.getValue()) {\n this._connected.next(true);\n }\n });\n tajAdmin.on(\"error\", (err) => {\n error(\"connection error\", err);\n });\n tajAdmin.on(\"disconnected\", () => {\n if (this._connected.getValue()) {\n this._connected.next(false);\n }\n });\n tajAdmin.on(\"accessDenied\", () => {\n if (this._connected.getValue()) {\n this._connected.next(false);\n }\n this.resetToken();\n });\n } catch (error) {\n if (error !== ErrNotConnected) {\n this.resetToken();\n }\n }\n\n this._connecting.next(false);\n }\n );\n }\n\n stop() {\n if (this._stopped.getValue()) {\n return;\n }\n\n const taj = this._tajriba.getValue();\n if (taj) {\n taj.removeAllListeners(\"connected\");\n taj.removeAllListeners(\"disconnected\");\n taj.stop();\n this._tajriba.next(undefined);\n }\n\n this.sub.unsubscribe();\n\n this._connecting.next(false);\n this._connected.next(false);\n this._stopped.next(true);\n }\n\n get connecting() {\n return this._connecting;\n }\n\n get connected() {\n return this._connected;\n }\n\n get stopped() {\n return this._stopped;\n }\n\n get admin() {\n return this._tajriba;\n }\n}\n","import { E_CANCELED, Mutex } from \"async-mutex\";\nimport { Observable, Subject, concatMap, takeUntil } from \"rxjs\";\nimport { warn } from \"../utils/console\";\n\nexport async function awaitObsValue<T>(\n obs: Observable<T>,\n value: T\n): Promise<T> {\n let res: (value: T) => void;\n const prom = new Promise<T>((r) => {\n res = r;\n });\n\n const unsub = obs.subscribe((val) => {\n if (val === value) {\n res(val);\n }\n });\n\n const val = await prom;\n unsub.unsubscribe();\n\n return val;\n}\n\nexport async function awaitObsValueExist<T>(obs: Observable<T>): Promise<T> {\n let res: (value: T) => void;\n const prom = new Promise<T>((r) => {\n res = r;\n });\n\n const unsub = obs.subscribe((val) => {\n if (val) {\n res(val);\n }\n });\n\n const val = await prom;\n unsub.unsubscribe();\n\n return val;\n}\n\nexport async function awaitObsValueChange<T>(obs: Observable<T>): Promise<T> {\n let res: (value: T) => void;\n const prom = new Promise<T>((r) => {\n res = r;\n });\n\n let once = false;\n let v: T;\n const unsub = obs.subscribe((val) => {\n if (once && val !== v) {\n res(val);\n }\n once = true;\n v = val;\n });\n\n const val = await prom;\n unsub.unsubscribe();\n\n return val;\n}\n\n// Subscribe to an observable and use the lock for sequential execution of async\n// functions.\nexport function lockedAsyncSubscribe<T>(\n mutex: Mutex,\n obs: Observable<T>,\n fn: (val: T) => Promise<any>\n) {\n return obs.subscribe({\n next: async (val) => {\n try {\n const release = await mutex.acquire();\n try {\n await fn(val);\n } catch (err) {\n console.error(\"error in async observable subscription\");\n console.error(err);\n } finally {\n release();\n }\n } catch (err) {\n if (err !== E_CANCELED) {\n console.error(\n \"error acquiring lock in async observable subscription\"\n );\n console.error(err);\n }\n }\n },\n });\n}\n\n// This does not behave correctly with a ReplaySubject\nexport function subscribeAsync<T>(\n obs: Observable<T>,\n fn: (val: T) => Promise<any>\n) {\n const cancel = new Subject<void>();\n obs.pipe(concatMap(fn), takeUntil(cancel)).subscribe();\n return {\n closed: false,\n unsubscribe() {\n if (this.closed) {\n warn(\"closing a closed async observable subscription\");\n return;\n }\n this.closed = true;\n cancel.next();\n cancel.unsubscribe();\n },\n };\n}\n\nexport interface AsyncObserver<T> {\n next: (value: T) => void;\n error: (err: any) => void;\n complete: () => void;\n}\n\nexport interface Unsubscribable {\n unsubscribe(): void;\n}\n\nexport interface AsyncSubscribable<T> {\n subscribe(observer: Partial<AsyncObserver<T>>): Promise<Unsubscribable>;\n}\n\n// A ReplaySubject that supports async subscribers\nexport class AsyncReplaySubject<T> {\n private values: T[] = [];\n private subscribers: ((val: T) => Promise<void>)[] = [];\n\n async next(value: T) {\n this.values.push(value);\n for (const sub of this.subscribers) {\n await sub(value);\n }\n }\n\n async subscribe({ next }: { next: (val: T) => Promise<void> }) {\n this.subscribers.push(next);\n for (const v of this.values) {\n await next(v);\n }\n\n let closed = false;\n return {\n get closed() {\n return closed;\n },\n unsubscribe: () => {\n if (closed) {\n warn(\"closing a closed async observable subscription\");\n return;\n }\n\n closed = true;\n this.subscribers = this.subscribers.filter((s) => s !== next);\n },\n };\n }\n}\n\n// A Subject that supports async subscribers\nexport class AsyncSubject<T> {\n private subscribers: ((val: T) => Promise<void>)[] = [];\n\n constructor(private value: T) {}\n\n async next(value: T) {\n for (const sub of this.subscribers) {\n await sub(value);\n }\n }\n\n async subscribe({ next }: { next: (val: T) => Promise<void> }) {\n this.subscribers.push(next);\n await next(this.value);\n\n let closed = false;\n return {\n get closed() {\n return closed;\n },\n unsubscribe: () => {\n if (closed) {\n warn(\"closing a closed async observable subscription\");\n return;\n }\n\n closed = true;\n this.subscribers = this.subscribers.filter((s) => s !== next);\n },\n };\n }\n}\n","import {\n AddGroupInput,\n AddScopeInput,\n AddStepInput,\n LinkInput,\n TransitionInput,\n} from \"@empirica/tajriba\";\nimport { Attribute } from \"../shared/attributes\";\nimport { ScopeConstructor } from \"../shared/scopes\";\nimport { Finalizer, TajribaAdminAccess } from \"./context\";\nimport { Scope, Scopes } from \"./scopes\";\nimport { ScopeSubscriptionInput } from \"./subscriptions\";\n\nexport type Subscriber<\n Context,\n Kinds extends { [key: string]: ScopeConstructor<Context, Kinds> }\n> = (subs: ListenersCollector<Context, Kinds>) => void;\n\nexport enum TajribaEvent {\n TransitionAdd = \"TRANSITION_ADD\",\n ParticipantConnect = \"PARTICIPANT_CONNECT\",\n ParticipantDisconnect = \"PARTICIPANT_DISCONNECT\",\n}\n\nexport enum ListernerPlacement {\n Before,\n None, // Not before or after\n After,\n}\n\nconst placementString = new Map<ListernerPlacement, string>();\nplacementString.set(ListernerPlacement.Before, \"before\");\nplacementString.set(ListernerPlacement.None, \"on\");\nplacementString.set(ListernerPlacement.After, \"after\");\n\nexport function PlacementString(placement: ListernerPlacement): string {\n return placementString.get(placement)!;\n}\n\nexport type SimpleListener<\n Context,\n Kinds extends { [key: string]: ScopeConstructor<Context, Kinds> }\n> = {\n placement: ListernerPlacement;\n callback: (ctx: EventContext<Context, Kinds>) => void;\n};\n\nexport type TajEventListener<Callback extends Function> = {\n placement: ListernerPlacement;\n event: TajribaEvent;\n callback: Callback;\n};\n\nexport type KindEventListener<Callback extends Function> = {\n placement: ListernerPlacement;\n kind: string;\n callback: Callback;\n};\n\nexport type AttributeEventListener<Callback extends Function> = {\n placement: ListernerPlacement;\n kind: string;\n key: string;\n callback: Callback;\n};\n\nexport type EvtCtxCallback<\n Context,\n Kinds extends { [key: string]: ScopeConstructor<Context, Kinds> }\n> = (ctx: EventContext<Context, Kinds>, props: any) => void;\n\nfunction unique<\n Context,\n Kinds extends { [key: string]: ScopeConstructor<Context, Kinds> },\n K extends keyof Kinds\n>(\n kind: K,\n placement: ListernerPlacement,\n callback: EvtCtxCallback<Context, Kinds>\n) {\n return async (ctx: EventContext<Context, Kinds>, props: any) => {\n const attr = props.attribute as Attribute;\n const scope = props[kind] as Scope<Context, Kinds>;\n if (\n !attr.id ||\n scope.get(`ran-${PlacementString(placement)}-${props.attrId}`)\n ) {\n return;\n }\n\n await callback(ctx, props);\n\n scope.set(`ran-${PlacementString(placement)}-${props.attrId}`, true);\n };\n}\n\n// Collects event listeners.\nexport class ListenersCollector<\n Context,\n Kinds extends { [key: string]: ScopeConstructor<Context, Kinds> }\n> {\n /** @internal */\n readonly starts: SimpleListener<Context, Kinds>[] = [];\n /** @internal */\n readonly readys: SimpleListener<Context, Kinds>[] = [];\n /** @internal */\n readonly tajEvents: TajEventListener<EvtCtxCallback<Context, Kinds>>[] = [];\n /** @internal */\n readonly kindListeners: KindEventListener<EvtCtxCallback<Context, Kinds>>[] =\n [];\n /** @internal */\n readonly attributeListeners: AttributeEventListener<\n EvtCtxCallback<Context, Kinds>\n >[] = [];\n\n /** @internal */\n private flusher: Flusher | undefined;\n\n /** @internal */\n setFlusher(flusher: Flusher) {\n this.flusher = flusher;\n }\n\n async flush(): Promise<void> {\n if (!this.flusher) {\n return;\n }\n\n await this.flusher.flush();\n }\n\n flushAfter(cb: () => Promise<void>): (() => Promise<void>) | void {\n if (!this.flusher) {\n return;\n }\n\n return this.flusher.flushAfter(cb);\n }\n\n get unique() {\n return new ListenersCollectorProxy<Context, Kinds>(this);\n }\n\n // start: first callback called.\n // ready: callback called when initial loading is finished.\n on(\n kind: \"start\" | \"ready\",\n callback: (ctx: EventContext<Context, Kinds>) => void\n ): void;\n\n // Attach to Tajriba Hooks.\n on(event: TajribaEvent, callback: EvtCtxCallback<Context, Kinds>): void;\n\n // Receive Scopes by Kind as they are fetched.\n on<Kind extends string>(\n kind: Kind,\n callback: EvtCtxCallback<Context, Kinds>\n ): void;\n\n // Receive Scope attributes as they are fetched.\n on<Kind extends keyof Kinds>(\n kind: Kind,\n key: string,\n callback: EvtCtxCallback<Context, Kinds>,\n uniqueCall?: boolean\n ): void;\n\n on(\n kindOrEvent: string,\n keyOrNodeIDOrEventOrCallback?:\n | string\n | TajribaEvent\n | EvtCtxCallback<Context, Kinds>\n | ((ctx: EventContext<Context, Kinds>) => void),\n callback?: EvtCtxCallback<Context, Kinds>\n ): void {\n this.registerListerner(\n ListernerPlacement.None,\n kindOrEvent,\n keyOrNodeIDOrEventOrCallback,\n callback\n );\n }\n\n before(\n kindOrEvent: string,\n keyOrNodeIDOrEventOrCallback?:\n | string\n | TajribaEvent\n | EvtCtxCallback<Context, Kinds>\n | ((ctx: EventContext<Context, Kinds>) => void),\n callback?: EvtCtxCallback<Context, Kinds>,\n uniqueCall?: boolean\n ): void {\n this.registerListerner(\n ListernerPlacement.Before,\n kindOrEvent,\n keyOrNodeIDOrEventOrCallback,\n callback,\n uniqueCall\n );\n }\n\n after(\n kindOrEvent: string,\n keyOrNodeIDOrEventOrCallback?:\n | string\n | TajribaEvent\n | EvtCtxCallback<Context, Kinds>\n | ((ctx: EventContext<Context, Kinds>) => void),\n callback?: EvtCtxCallback<Context, Kinds>,\n uniqueCall?: boolean\n ): void {\n this.registerListerner(\n ListernerPlacement.After,\n kindOrEvent,\n keyOrNodeIDOrEventOrCallback,\n callback,\n uniqueCall\n );\n }\n\n protected registerListerner(\n placement: ListernerPlacement,\n kindOrEvent: string,\n keyOrNodeIDOrEventOrCallback?:\n | string\n | TajribaEvent\n | EvtCtxCallback<Context, Kinds>\n | ((ctx: EventContext<Context, Kinds>) => void),\n callback?: EvtCtxCallback<Context, Kinds>,\n uniqueCall = false\n ): void {\n if (kindOrEvent === \"start\") {\n if (callback) {\n throw new Error(\"start event only accepts 2 arguments\");\n }\n\n if (typeof keyOrNodeIDOrEventOrCallback !== \"function\") {\n throw new Error(\"second argument expected to be a callback\");\n }\n\n this.starts.push({\n placement,\n callback: keyOrNodeIDOrEventOrCallback as (\n ctx: EventContext<Context, Kinds>\n ) => void,\n });\n\n return;\n }\n\n if (kindOrEvent === \"ready\") {\n if (callback) {\n throw new Error(\"ready event only accepts 2 arguments\");\n }\n\n if (typeof keyOrNodeIDOrEventOrCallback !== \"function\") {\n throw new Error(\"second argument expected to be a callback\");\n }\n\n this.readys.push({\n placement,\n callback: keyOrNodeIDOrEventOrCallback as (\n ctx: EventContext<Context, Kinds>\n ) => void,\n });\n\n return;\n }\n\n if (Object.values(TajribaEvent).includes(kindOrEvent as any)) {\n if (typeof keyOrNodeIDOrEventOrCallback !== \"function\") {\n throw new Error(\"second argument expected to be a callback\");\n }\n\n this.tajEvents.push({\n placement,\n event: <TajribaEvent>kindOrEvent,\n callback: keyOrNodeIDOrEventOrCallback,\n });\n\n return;\n }\n\n if (typeof keyOrNodeIDOrEventOrCallback === \"function\") {\n this.kindListeners.push({\n placement,\n kind: kindOrEvent,\n callback: keyOrNodeIDOrEventOrCallback,\n });\n } else {\n if (typeof keyOrNodeIDOrEventOrCallback !== \"string\") {\n throw new Error(\"second argument expected to be an attribute key\");\n }\n if (typeof callback !== \"function\") {\n throw new Error(\"third argument expected to be a callback\");\n }\n\n if (uniqueCall) {\n callback = unique(kindOrEvent, placement, callback);\n }\n\n this.attributeListeners.push({\n placement,\n kind: kindOrEvent,\n key: keyOrNodeIDOrEventOrCallback,\n callback,\n });\n }\n }\n}\n\n// Collects event listeners.\nexport class ListenersCollectorProxy<\n Context,\n Kinds extends { [key: string]: ScopeConstructor<Context, Kinds> }\n> extends ListenersCollector<Context, Kinds> {\n constructor(private coll: ListenersCollector<Context, Kinds>) {\n super();\n }\n\n protected registerListerner(\n placement: ListernerPlacement,\n kindOrEvent: string,\n keyOrNodeIDOrEventOrCallback?:\n | string\n | TajribaEvent\n | EvtCtxCallback<Context, Kinds>\n | ((ctx: EventContext<Context, Kinds>) => void),\n callback?: EvtCtxCallback<Context, Kinds>\n ): void {\n if (\n kindOrEvent === \"start\" ||\n kindOrEvent === \"ready\" ||\n Object.values(TajribaEvent).includes(kindOrEvent as any) ||\n typeof keyOrNodeIDOrEventOrCallback === \"function\"\n ) {\n throw new Error(\"only attribute listeners can be unique\");\n }\n\n super.registerListerner(\n placement,\n kindOrEvent,\n keyOrNodeIDOrEventOrCallback,\n callback,\n true\n );\n\n while (true) {\n const listener = this.attributeListeners.pop();\n if (!listener) {\n break;\n }\n\n this.coll.attributeListeners.push(listener);\n }\n }\n}\n\n// Context passed to listerners on new event allowing to subscrive to more data\n// and access data.\nexport interface SubscriptionCollector {\n scopeSub: (...inputs: Partial<ScopeSubscriptionInput>[]) => void;\n participantsSub: () => void;\n transitionsSub: (stepID: string) => void;\n}\n\n// Context passed to listerners on new event allowing to subscrive to more data\n// and access data.\nexport class EventContext<\n Context,\n Kinds extends { [key: string]: ScopeConstructor<Context, Kinds> }\n> {\n constructor(\n /** @internal */\n private subs: SubscriptionCollector,\n /** @internal */\n private taj: TajribaAdminAccess,\n /** @internal */\n private scopes: Scopes<Context, Kinds>,\n /** @internal */\n private flusher: Flusher\n ) {}\n\n async flush(): Promise<void> {\n await this.flusher.flush();\n }\n\n flushAfter(cb: () => Promise<void>): (() => Promise<void>) | void {\n return this.flusher.flushAfter(cb);\n }\n\n scopesByKind<T extends Scope<Context, Kinds>>(kind: keyof Kinds) {\n return this.scopes.byKind<T>(kind) as Map<string, T>;\n }\n\n scopesByKindID<T extends Scope<Context, Kinds>>(\n kind: keyof Kinds,\n id: string\n ) {\n return this.scopes.byKind<T>(kind).get(id);\n }\n\n scopesByKindMatching<T extends Scope<Context, Kinds>>(\n kind: keyof Kinds,\n key: string,\n val: string\n ): T[] {\n const scopes = Array.from(this.scopes.byKind(kind).values());\n return scopes.filter((s) => s.get(key) === val) as T[];\n }\n\n scopeSub(...inputs: Partial<ScopeSubscriptionInput>[]) {\n for (const input of inputs) {\n this.subs.scopeSub(input);\n }\n }\n\n participantsSub() {\n this.subs.participantsSub();\n }\n\n transitionsSub(stepID: string) {\n this.subs.transitionsSub(stepID);\n }\n\n // c8 ignore: the TajribaAdminAccess proxy functions are tested elswhere\n /* c8 ignore next 3 */\n addScopes(input: AddScopeInput[]) {\n return this.taj.addScopes(input);\n }\n\n /* c8 ignore next 3 */\n addGroups(input: AddGroupInput[]) {\n return this.taj.addGroups(input);\n }\n\n /* c8 ignore next 3 */\n addLinks(input: LinkInput[]) {\n return this.taj.addLinks(input);\n }\n\n /* c8 ignore next 3 */\n addSteps(input: AddStepInput[]) {\n return this.taj.addSteps(input);\n }\n\n /* c8 ignore next 3 */\n addTransitions(input: TransitionInput[]) {\n return this.taj.addTransitions(input);\n }\n\n protected addFinalizer(cb: Finalizer) {\n this.taj.addFinalizer(cb);\n }\n\n /* c8 ignore next 3 */\n get globals() {\n return this.taj.globals;\n }\n}\n\nexport class Flusher {\n constructor(\n /** @internal */\n private postCallback: (() => Promise<void>) | undefined\n ) {}\n\n async flush(): Promise<void> {\n if (!this.postCallback) {\n return;\n }\n\n await this.postCallback();\n }\n\n flushAfter(cb: () => Promise<void>): (() => Promise<void>) | void {\n if (!this.postCallback) {\n cb();\n return;\n }\n\n return async () => {\n await cb();\n if (this.postCallback) {\n await this.postCallback();\n }\n };\n }\n}\n","import { EventType, TajribaAdmin } from \"@empirica/tajriba\";\nimport { Subject } from \"rxjs\";\nimport { error } from \"../utils/console\";\nimport { PromiseHandle, promiseHandle } from \"./promises\";\n\nexport interface Participant {\n id: string;\n identifier: string;\n}\n\nexport interface Connection {\n participant: Participant;\n connected: boolean;\n}\n\nexport interface ConnectionMsg {\n connection?: Connection;\n done: boolean;\n}\n\nexport async function participantsSub(\n taj: TajribaAdmin,\n connections: Subject<ConnectionMsg>,\n participants: Map<string, Participant>\n) {\n let handle: PromiseHandle | undefined = promiseHandle();\n taj.onEvent({ eventTypes: [EventType.ParticipantConnected] }).subscribe({\n next({ node, done }) {\n if (!node) {\n if (done) {\n if (handle) {\n handle?.result();\n\n connections.next({ done: true });\n }\n\n return;\n }\n error(`received no participant on connected`);\n\n return;\n }\n\n if (node.__typename !== \"Participant\") {\n error(`received non-participant on connected`);\n\n return;\n }\n\n const part = {\n id: node.id,\n identifier: node.identifier,\n };\n\n participants.set(node.id, part);\n\n connections.next({\n connection: {\n participant: part,\n connected: true,\n },\n done,\n });\n\n if (handle && done) {\n handle.result();\n }\n },\n });\n\n taj.onEvent({ eventTypes: [EventType.ParticipantDisconnect] }).subscribe({\n next({ node }) {\n if (!node) {\n error(`received no participant on disconnect`);\n\n return;\n }\n\n if (node.__typename !== \"Participant\") {\n error(`received non-participant on disconnect`);\n\n return;\n }\n\n participants.delete(node.id);\n\n connections.next({\n connection: {\n participant: {\n id: node.id,\n identifier: node.identifier,\n },\n connected: false,\n },\n done: true,\n });\n },\n });\n\n await handle.promise;\n handle = undefined;\n}\n","export interface PromiseHandle<T = void> {\n promise: Promise<T>;\n result: (value: T) => void;\n}\n\nexport function promiseHandle<T = void>(): PromiseHandle<T> {\n let ret = {} as PromiseHandle<T>;\n ret.promise = new Promise<T>((r) => {\n ret.result = r;\n });\n\n return ret;\n}\n","import {\n AddGroupInput,\n AddScopeInput,\n AddStepInput,\n LinkInput,\n TransitionInput,\n} from \"@empirica/tajriba\";\nimport { Observable, ReplaySubject } from \"rxjs\";\nimport {\n Scope as SharedScope,\n ScopeConstructor,\n ScopeIdent,\n Scopes as SharedScopes,\n ScopeUpdate,\n} from \"../shared/scopes\";\nimport { Attributes } from \"./attributes\";\nimport { Finalizer, TajribaAdminAccess } from \"./context\";\n\nexport type ScopeMsg<\n Context,\n Kinds extends { [key: string]: ScopeConstructor<Context, Kinds> }\n> = {\n scope?: Scope<Context, Kinds>;\n done: boolean;\n};\n\nexport class Scopes<\n Context,\n Kinds extends { [key: string]: ScopeConstructor<Context, Kinds> }\n> extends SharedScopes<Context, Kinds, Scope<Context, Kinds>> {\n private kindSubs = new Map<\n keyof Kinds,\n ReplaySubject<ScopeMsg<Context, Kinds>>\n >();\n\n constructor(\n scopesObs: Observable<ScopeUpdate>,\n donesObs: Observable<string[]>,\n ctx: Context,\n kinds: Kinds,\n attributes: Attributes,\n readonly taj: TajribaAdminAccess\n ) {\n super(scopesObs, donesObs, ctx, kinds, attributes);\n }\n\n /** @internal */\n subscribeKind(kind: keyof Kinds): Observable<ScopeMsg<Context, Kinds>> {\n let sub = this.kindSubs.get(kind);\n if (!sub) {\n sub = new ReplaySubject<ScopeMsg<Context, Kinds>>();\n this.kindSubs.set(kind, sub);\n\n const scopes = this.byKind(kind);\n\n setTimeout(() => {\n if (scopes.size === 0) {\n sub!.next({ done: true });\n\n return;\n }\n\n let count = 0;\n for (const [_, scope] of scopes) {\n count++;\n sub!.next({ scope, done: scopes.size === count });\n }\n }, 0);\n }\n\n return sub!;\n }\n\n protected next(scopeIDs: string[]) {\n for (const [_, scopeReplaySubject] of this.scopes) {\n const scope = scopeReplaySubject.getValue();\n if (this.newScopes.get(scope.id) && scopeIDs.includes(scope.id)) {\n const kindSub = this.kindSubs.get(scope.kind);\n if (kindSub) {\n kindSub.next({ scope, done: true });\n }\n this.newScopes.set(scope.id, false);\n }\n }\n\n super.next(scopeIDs);\n }\n\n protected create(\n scopeClass: ScopeConstructor<Context, Kinds>,\n scope: ScopeIdent\n ) {\n return new scopeClass!(this.ctx, scope, this, this.attributes) as Scope<\n Context,\n Kinds\n >;\n }\n}\n\nexport class Scope<\n Context,\n Kinds extends { [key: string]: ScopeConstructor<Context, Kinds> }\n> extends SharedScope<Context, Kinds> {\n /**\n * @internal\n */\n readonly taj: TajribaAdminAccess;\n\n constructor(\n ctx: Context,\n scope: ScopeIdent,\n private scopes: Scopes<Context, Kinds>,\n attributes: Attributes\n ) {\n super(ctx, scope, attributes);\n this.taj = scopes.taj;\n }\n\n protected scopeByID<T extends Scope<Context, Kinds>>(\n id: string\n ): T | undefined {\n return this.scopes.scope(id) as T | undefined;\n }\n\n protected scopeByKey<T extends Scope<Context, Kinds>>(\n key: string\n ): T | undefined {\n const id = this.get(key);\n if (!id || typeof id !== \"string\") {\n return;\n }\n\n re