@empirica/core
Version:
Empirica Core
1 lines • 153 kB
Source Map (JSON)
{"version":3,"sources":["../src/admin/index.ts","../src/shared/globals.ts","../src/shared/tajriba_connection.ts","../src/utils/console.ts","../src/utils/object.ts","../src/admin/attributes.ts","../src/shared/attributes.ts","../src/admin/connection.ts","../src/admin/observables.ts","../src/admin/context.ts","../src/admin/runloop.ts","../src/admin/cake.ts","../src/admin/events.ts","../src/admin/promises.ts","../src/admin/globals.ts","../src/admin/participants.ts","../src/admin/scopes.ts","../src/shared/scopes.ts","../src/admin/subscriptions.ts","../src/admin/transitions.ts","../src/admin/token_file.ts"],"sourcesContent":["export type {\n AttributeChange,\n AttributeOptions,\n AttributeUpdate,\n Attribute as SharedAttribute,\n Attributes as SharedAttributes,\n} from \"../shared/attributes\";\nexport { Globals as SharedGlobals } from \"../shared/globals\";\nexport type { Constructor } from \"../shared/helpers\";\nexport type {\n Attributable,\n AttributeInput,\n ScopeConstructor,\n ScopeIdent,\n ScopeUpdate,\n Scope as SharedScope,\n} from \"../shared/scopes\";\nexport { TajribaConnection } from \"../shared/tajriba_connection\";\nexport type { Json, JsonArray, JsonValue } from \"../utils/json\";\nexport { AttributeMsg, Attributes } from \"./attributes\";\nexport { AdminConnection } from \"./connection\";\nexport { AdminContext, TajribaAdminAccess } from \"./context\";\nexport type {\n AddLinkPayload,\n AddScopePayload,\n AddTransitionPayload,\n Finalizer,\n StepPayload,\n} from \"./context\";\nexport {\n EventContext,\n EvtCtxCallback,\n ListenersCollector,\n ListenersCollectorProxy,\n TajribaEvent,\n} from \"./events\";\nexport type { Subscriber } from \"./events\";\nexport { Globals } from \"./globals\";\nexport { participantsSub } from \"./participants\";\nexport type { Connection, ConnectionMsg, Participant } from \"./participants\";\nexport { Scope, Scopes } from \"./scopes\";\nexport type { KV, ScopeSubscriptionInput, Subs } from \"./subscriptions\";\nexport type { Step } from \"./transitions\";\n","import { SubAttributesPayload } from \"@empirica/tajriba\";\nimport { BehaviorSubject, Observable } from \"rxjs\";\nimport { JsonValue } from \"../utils/json\";\n\nexport class Globals {\n protected attrs = new Map<string, BehaviorSubject<JsonValue | undefined>>();\n private updates = new Map<string, JsonValue | undefined>();\n public self: BehaviorSubject<Globals | undefined>;\n\n constructor(globals: Observable<SubAttributesPayload>) {\n this.self = new BehaviorSubject<Globals | undefined>(undefined);\n\n globals.subscribe({\n next: ({ attribute, done }) => {\n if (attribute) {\n let val = undefined;\n if (attribute.val) {\n val = JSON.parse(attribute.val);\n }\n\n this.updates.set(attribute.key, val);\n }\n\n if (done) {\n for (const [key, val] of this.updates) {\n this.obs(key).next(val);\n }\n\n this.updates.clear();\n\n if (this.self) {\n this.self.next(this);\n }\n }\n },\n });\n }\n\n get(key: string): JsonValue | undefined {\n const o = this.attrs.get(key);\n if (o) {\n return o.getValue();\n }\n\n return undefined;\n }\n\n obs(key: string) {\n let o = this.attrs.get(key);\n if (!o) {\n o = new BehaviorSubject<JsonValue | undefined>(undefined);\n this.attrs.set(key, o);\n }\n\n return o;\n }\n}\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\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","/* 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 { 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","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 State,\n TransitionInput,\n} from \"@empirica/tajriba\";\nimport { merge, Subject, SubscriptionLike } from \"rxjs\";\nimport { ScopeConstructor } from \"../shared/scopes\";\nimport { TajribaConnection } from \"../shared/tajriba_connection\";\nimport { error, warn } from \"../utils/console\";\nimport { AdminConnection } from \"./connection\";\nimport { ListenersCollector, Subscriber } from \"./events\";\nimport { Globals } from \"./globals\";\nimport { subscribeAsync } from \"./observables\";\nimport { Runloop } from \"./runloop\";\nimport {\n FileTokenStorage,\n MemTokenStorage,\n SavedTokenStorage,\n TokenProvider,\n} from \"./token_file\";\n\nexport class AdminContext<\n Context,\n Kinds extends { [key: string]: ScopeConstructor<Context, Kinds> }\n> {\n readonly tajriba: TajribaConnection;\n public adminConn: AdminConnection | undefined;\n private sub?: SubscriptionLike;\n private runloop: Runloop<Context, Kinds> | undefined;\n private adminSubs = new Subject<\n Subscriber<Context, Kinds> | ListenersCollector<Context, Kinds>\n >();\n private adminStop = new Subject<void>();\n private subs: (\n | Subscriber<Context, Kinds>\n | ListenersCollector<Context, Kinds>\n )[] = [];\n\n private constructor(url: string, private ctx: Context, private kinds: Kinds) {\n this.tajriba = new TajribaConnection(url);\n }\n\n /**\n * @internal\n *\n * NOTE: For testing purposes only.\n */\n get _runloop() {\n return this.runloop;\n }\n\n static async init<\n Context,\n Kinds extends { [key: string]: ScopeConstructor<Context, Kinds> }\n >(\n url: string,\n tokenFile: string,\n serviceName: string,\n serviceRegistrationToken: string,\n ctx: Context,\n kinds: Kinds\n ) {\n const adminContext = new this(url, ctx, kinds);\n const reset = new Subject<void>();\n let strg: SavedTokenStorage;\n if (tokenFile === \":mem:\") {\n strg = new MemTokenStorage();\n } else {\n strg = await FileTokenStorage.init(tokenFile, reset);\n }\n\n const tp = new TokenProvider(\n adminContext.tajriba,\n strg,\n serviceName,\n serviceRegistrationToken\n );\n adminContext.adminConn = new AdminConnection(\n adminContext.tajriba,\n tp.tokens,\n reset.next.bind(reset)\n );\n\n adminContext.sub = subscribeAsync(\n merge(adminContext.tajriba.connected, adminContext.adminConn.connected),\n async () => {\n await adminContext.initOrStop();\n }\n );\n\n return adminContext;\n }\n\n async stop() {\n this.sub?.unsubscribe();\n delete this.sub;\n await this.stopSubs();\n this.tajriba.stop();\n this.adminConn?.stop();\n }\n\n register(\n subscriber: Subscriber<Context, Kinds> | ListenersCollector<Context, Kinds>\n ) {\n this.subs.push(subscriber);\n if (this.runloop) {\n this.adminSubs.next(subscriber);\n }\n }\n\n private async initOrStop() {\n // Forcing this.adminConn since adminConn is always created by init().\n if (\n this.tajriba.connected.getValue() &&\n this.adminConn!.connected.getValue()\n ) {\n await this.initSubs();\n } else {\n await this.stopSubs();\n }\n }\n\n private async initSubs() {\n if (this.runloop) {\n return;\n }\n\n /* c8 ignore next 5 */\n if (!this.adminConn) {\n // This condition is nearly impossible to create\n warn(\"context: admin not connected\");\n return;\n }\n\n /* c8 ignore next 6 */\n const tajAdmin = this.adminConn.admin.getValue();\n if (!tajAdmin) {\n // This condition is nearly impossible to create\n warn(\"context: admin not connected\");\n return;\n }\n\n let globalScopeID: string | undefined;\n try {\n const scopes = await tajAdmin.scopes({\n filter: { kinds: [\"global\"] },\n first: 100,\n });\n globalScopeID = scopes!.edges[0]?.node.id;\n if (!globalScopeID) {\n warn(\"context: global scopeID not found\");\n\n return;\n }\n } catch (err) {\n error(`context: global scopeID not fetched: ${err}`);\n\n return;\n }\n\n this.runloop = new Runloop(\n this.adminConn,\n this.ctx,\n this.kinds,\n globalScopeID,\n this.adminSubs,\n this.adminStop\n );\n\n for (const sub of this.subs) {\n this.adminSubs.next(sub);\n }\n }\n\n private async stopSubs() {\n this.adminStop.next();\n if (this.runloop) {\n await this.runloop.stop();\n this.runloop = undefined;\n }\n }\n}\n\nexport interface StepPayload {\n id: string;\n duration: number;\n}\n\nexport interface AddLinkPayload {\n nodes: { id: string }[];\n participants: { id: string }[];\n}\n\nexport interface AddTransitionPayload {\n id: string;\n from: State;\n to: State;\n}\n\nexport interface AddScopePayload {\n id: string;\n name?: string | null | undefined;\n kind?: string | null | undefined;\n attributes: {\n edges: {\n node: {\n id: string;\n private: boolean;\n protected: boolean;\n immutable: boolean;\n ephemeral: boolean;\n key: string;\n val?: string | null | undefined;\n index?: number | null | undefined;\n };\n }[];\n };\n}\n\nexport type Finalizer = () => Promise<void>;\n\nexport class TajribaAdminAccess {\n constructor(\n readonly addFinalizer: (cb: Finalizer) => void,\n readonly addScopes: (input: AddScopeInput[]) => Promise<AddScopePayload[]>,\n readonly addGroups: (input: AddGroupInput[]) => Promise<{ id: string }[]>,\n readonly addLinks: (input: LinkInput[]) => Promise<AddLinkPayload[]>,\n readonly addSteps: (input: AddStepInput[]) => Promise<StepPayload[]>,\n readonly addTransitions: (\n input: TransitionInput[]\n ) => Promise<AddTransitionPayload[]>,\n readonly globals: Globals\n ) {}\n}\n","import {\n AddGroupInput,\n AddScopeInput,\n AddStepInput,\n LinkInput,\n ScopedAttributesInput,\n SetAttributeInput,\n TransitionInput,\n} from \"@empirica/tajriba\";\nimport {\n BehaviorSubject,\n Observable,\n ReplaySubject,\n Subject,\n Subscription,\n} from \"rxjs\";\nimport { AttributeChange, AttributeUpdate } from \"../shared/attributes\";\nimport { ScopeConstructor, ScopeIdent, ScopeUpdate } from \"../shared/scopes\";\nimport { error, warn } from \"../utils/console\";\nimport { Attributes } from \"./attributes\";\nimport { Cake } from \"./cake\";\nimport { AdminConnection } from \"./connection\";\nimport {\n AddLinkPayload,\n AddScopePayload,\n AddTransitionPayload,\n Finalizer,\n StepPayload,\n TajribaAdminAccess,\n} from \"./context\";\nimport {\n EventContext,\n Flusher,\n ListenersCollector,\n Subscriber,\n} from \"./events\";\nimport { Globals } from \"./globals\";\nimport { awaitObsValue, subscribeAsync } from \"./observables\";\nimport { ConnectionMsg, Participant, participantsSub } from \"./participants\";\nimport { Scopes } from \"./scopes\";\nimport { Subs, Subscriptions } from \"./subscriptions\";\nimport { Transition, transitionsSub } from \"./transitions\";\n\nexport class Runloop<\n Context,\n Kinds extends { [key: string]: ScopeConstructor<Context, Kinds> }\n> {\n private subs = new Subscriptions<Context, Kinds>();\n private evtctx: EventContext<Context, Kinds>;\n private participants = new Map<string, Participant>();\n private connections = new ReplaySubject<ConnectionMsg>();\n private transitions = new Subject<Transition>();\n private scopesSub = new Subject<ScopeUpdate>();\n private attributesSub = new Subject<AttributeUpdate>();\n private donesSub = new Subject<string[]>();\n private attributes: Attributes;\n private finalizers: Finalizer[] = [];\n private groupPromises: Promise<{ id: string }[]>[] = [];\n private stepPromises: Promise<StepPayload[]>[] = [];\n private scopePromises: Promise<AddScopePayload[]>[] = [];\n private linkPromises: Promise<AddLinkPayload>[] = [];\n private transitionPromises: Promise<AddTransitionPayload>[] = [];\n private attributeInputs: SetAttributeInput[] = [];\n private scopes: Scopes<Context, Kinds>;\n private cake: Cake<Context, Kinds>;\n private running = new BehaviorSubject<boolean>(false);\n private stopped = false;\n\n constructor(\n private conn: AdminConnection,\n private ctx: Context,\n private kinds: Kinds,\n globalScopeID: string,\n subs: Observable<\n Subscriber<Context, Kinds> | ListenersCollector<Context, Kinds>\n >,\n stop: Observable<void>\n ) {\n this.attributes = new Attributes(\n this.attributesSub,\n this.donesSub,\n this.setAttributes.bind(this)\n );\n\n const mut = new TajribaAdminAccess(\n this.addFinalizer.bind(this),\n this.addScopes.bind(this),\n this.addGroups.bind(this),\n this.addLinks.bind(this),\n this.addSteps.bind(this),\n this.addTransitions.bind(this),\n new Globals(\n this.taj.globalAttributes(),\n globalScopeID,\n this.setAttributes.bind(this)\n )\n );\n\n this.scopes = new Scopes<Context, Kinds>(\n this.scopesSub,\n this.donesSub,\n this.ctx,\n this.kinds,\n this.attributes,\n mut\n );\n\n this.evtctx = new EventContext(\n this.subs,\n mut,\n this.scopes,\n new Flusher(this.postCallback.bind(this, true))\n );\n\n this.cake = new Cake(\n this.evtctx,\n this.scopes.scope.bind(this.scopes),\n this.scopes.subscribeKind.bind(this.scopes),\n (kind: keyof Kinds, key: string) =>\n this.attributes.subscribeAttribute(<string>kind, key),\n this.connections,\n this.transitions\n );\n this.cake.postCallback = this.postCallback.bind(this, true);\n\n const subsSub = subscribeAsync(subs, async (subscriber) => {\n let listeners: ListenersCollector<Context, Kinds>;\n if (typeof subscriber === \"function\") {\n listeners = new ListenersCollector<Context, Kinds>();\n subscriber(listeners);\n } else {\n listeners = subscriber;\n }\n\n await this.cake.add(listeners);\n });\n\n let stopSub: Subscription;\n stopSub = stop.subscribe({\n next: () => {\n subsSub.unsubscribe();\n stopSub.unsubscribe();\n },\n });\n }\n\n /**\n * @internal\n *\n * NOTE: For testing purposes only.\n */\n get _attributes() {\n return this.attributes;\n }\n\n /**\n * @internal\n *\n * NOTE: For testing purposes only.\n */\n get _scopes() {\n return this.scopes;\n }\n\n /**\n * @internal\n *\n * NOTE: For testing purposes only.\n */\n async _postCallback() {\n return await this.postCallback(true);\n }\n\n private async postCallback(final: boolean) {\n if (this.stopped) {\n return;\n }\n\n this.running.next(true);\n\n const promises: Promise<any>[] = [];\n\n const subs = this.subs.newSubs();\n if (subs) {\n promises.push(this.processNewSub(subs));\n }\n\n promises.push(...this.groupPromises);\n this.groupPromises = [];\n promises.push(...this.stepPromises);\n this.stepPromises = [];\n promises.push(...this.scopePromises);\n this.scopePromises = [];\n promises.push(...this.linkPromises);\n this.linkPromises = [];\n promises.push(...this.transitionPromises);\n this.transitionPromises = [];\n\n if (this.attributeInputs.length > 0) {\n // If the same key is set twice within the same loop, only send 1\n // setAttribute update.\n const uniqueAttrs: { [key: string]: SetAttributeInput } = {};\n let appendCount = 0;\n for (const attr of this.attributeInputs) {\n if (!attr.nodeID) {\n error(`runloop: attribute without nodeID: ${JSON.stringify(attr)}`);\n continue;\n }\n\n let key = `${attr.nodeID}-${attr.key}`;\n\n if (attr.append) {\n key += `-${appendCount++}`;\n }\n\n if (attr.index !== undefined) {\n key += `-${attr.index}`;\n }\n\n uniqueAttrs[key] = attr;\n }\n\n const attrs = Object.values(uniqueAttrs);\n\n promises.push(this.taj.setAttributes(attrs));\n this.attributeInputs = [];\n }\n\n const res = await Promise.allSettled(promises);\n for (const r of res) {\n if (r.status === \"rejected\") {\n // We can ignore invalid transition errors, as they are likely due to\n // the same transition triggered concurrently from multiple places.\n if (\n r.reason instanceof String &&\n r.reason.includes(\"invalid transition\")\n ) {\n continue;\n }\n\n warn(`failed load: ${r.reason}`);\n }\n }\n\n const finalizer = this.finalizers.shift();\n if (finalizer) {\n await finalizer();\n await this.postCallback(false);\n }\n\n if (final) {\n this.running.next(false);\n }\n }\n\n async stop() {\n await this.cake.stop();\n await awaitObsValue(this.running, false);\n this.stopped = true;\n }\n\n addFinalizer(cb: Finalizer) {\n this.finalizers.push(cb);\n }\n\n async addScopes(inputs: AddScopeInput[]) {\n if (this.stopped) {\n return [];\n }\n\n const addScopes = this.taj\n .addScopes(inputs)\n .then((scopes) => {\n for (const scope of scopes) {\n for (const attrEdge of scope.attributes.edges) {\n this.attributesSub.next({\n attribute: attrEdge.node as AttributeChange,\n removed: false,\n });\n }\n\n this.scopesSub.next({\n scope: scope as ScopeIdent,\n removed: false,\n });\n }\n\n this.donesSub.next(scopes.map((s) => s.id));\n\n return scopes;\n })\n .catch((err) => {\n warn(err.message);\n return [];\n });\n\n this.scopePromises.push(addScopes);\n\n return addScopes;\n }\n\n async addGroups(inputs: AddGroupInput[]) {\n if (this.stopped) {\n return [];\n }\n\n const addGroups = this.taj.addGroups(inputs);\n this.groupPromises.push(addGroups);\n return addGroups;\n }\n\n async addLinks(inputs: LinkInput[]) {\n if (this.stopped) {\n return [];\n }\n\n const proms: Promise<AddLinkPayload>[] = [];\n for (const input of inputs) {\n const linkPromise = this.taj.addLink(input);\n this.linkPromises.push(linkPromise);\n proms.push(linkPromise);\n }\n\n return Promise.all(proms);\n }\n\n async addSteps(inputs: AddStepInput[]) {\n if (this.stopped) {\n return [];\n }\n\n const addSteps = this.taj.addSteps(inputs);\n this.stepPromises.push(addSteps);\n return addSteps;\n }\n\n async addTransitions(inputs: TransitionInput[]) {\n if (this.stopped) {\n return [];\n }\n\n const proms: Promise<AddTransitionPayload>[] = [];\n for (const input of inputs) {\n const transitionPromise = this.taj.transition(input);\n this.transitionPromises.push(transitionPromise);\n proms.push(transitionPromise);\n }\n\n return Promise.all(proms);\n }\n\n async setAttributes(inputs: SetAttributeInput[]) {\n this.attributeInputs.push(...inputs);\n }\n\n // TODO ADD iteration attributes per scope, only first 100...\n private loadAllScopes(filters: ScopedAttributesInput[], after?: any) {\n this.taj.scopes({ filter: filters, first: 100, after }).then((conn) => {\n const scopes: { [key: string]: ScopeIdent } = {};\n for (const edge of conn?.edges || []) {\n for (const attrEdge of edge.node.attributes.edges || []) {\n this.attributesSub.next({\n attribute: attrEdge.node as AttributeChange,\n removed: false,\n });\n }\n\n scopes[edge.node.id] = edge.node as ScopeIdent;\n }\n\n for (const scope of Object.values(scopes)) {\n this.scopesSub.next({\n scope,\n removed: false,\n });\n }\n\n if (conn?.pageInfo.hasNextPage && conn?.pageInfo.endCursor) {\n return this.loadAllScopes(filters, conn?.pageInfo.endCursor);\n }\n });\n }\n\n private async processNewScopesSub(filters: ScopedAttributesInput[]) {\n if (filters.length === 0) {\n return;\n }\n\n let resolve: (