• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

WolferyScripting / resclient-ts / #30

25 Aug 2025 07:17PM UTC coverage: 50.87% (-0.3%) from 51.153%
#30

push

DonovanDMC
1.1.2

228 of 286 branches covered (79.72%)

Branch coverage included in aggregate %.

1584 of 3276 relevant lines covered (48.35%)

10.54 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

33.36
/lib/models/ResClient.ts
1
import CacheItem from "./CacheItem.js";
1✔
2
import TypeList, { type ItemFactory } from "./TypeList.js";
1✔
3
import ResCollection from "./ResCollection.js";
1✔
4
import ResError from "./ResError.js";
1✔
5
import ResModel from "./ResModel.js";
1✔
6
import ResRef from "./ResRef.js";
1✔
7
import eventBus, { type EventBus } from "../includes/eventbus/index.js";
1✔
8
import {
1✔
9
    ACTION_DELETE,
1✔
10
    COLLECTION_TYPE,
1✔
11
    ERROR_TYPE,
1✔
12
    LEGACY_PROTOCOL,
1✔
13
    MODEL_TYPE,
1✔
14
    States,
1✔
15
    SUPPORTED_PROTOCOL,
1✔
16
    RESOURCE_TYPES,
1✔
17
    versionToInt,
1✔
18
    CURRENT_PROTOCOL,
1✔
19
    RECONNECT_DELAY,
1✔
20
    SUBSCRIBE_STALE_DELAY,
1✔
21
    ErrorCodes
1✔
22
} from "../Constants.js";
1✔
23
import type {
1✔
24
    AddEventData,
1✔
25
    RemoveEventData,
1✔
26
    ChangeEventData,
1✔
27
    ErrorData,
1✔
28
    Ref,
1✔
29
    Shared,
1✔
30
    RIDRef,
1✔
31
    Refs,
1✔
32
    AnyFunction,
1✔
33
    AnyObject,
1✔
34
    AnyRes,
1✔
35
    AnyClass
1✔
36
} from "../util/types.js";
1✔
37
import { Debug } from "../util/Debug.js";
1✔
38
import ensurePromiseReturn from "../util/ensurePromiseReturn.js";
1✔
39
import Properties from "../util/Properties.js";
1✔
40
import { lcsDiffAsync } from "../util/util.js";
1✔
41
import WebSocket, { type MessageEvent } from "ws";
1✔
42
import assert from "node:assert";
1✔
43

1✔
44
export type OnConnectFunction = (api: ResClient) => unknown;
1✔
45
export type OnConnectErrorFunction = (api: ResClient, err: unknown) => unknown;
1✔
46
export interface ClientOptions {
1✔
47
    defaultCollectionFactory?: ItemFactory<ResCollection>;
1✔
48
    defaultErrorFactory?: ItemFactory<ResError>;
1✔
49
    defaultModelFactory?: ItemFactory<ResModel>;
1✔
50
    eventBus?: EventBus;
1✔
51
    namespace?: string;
1✔
52
    onConnect?: OnConnectFunction;
1✔
53
    onConnectError?: OnConnectErrorFunction;
1✔
54
    retryOnTooActive?: boolean;
1✔
55
}
1✔
56

1✔
57
export interface Request {
1✔
58
    method: string;
1✔
59
    params: unknown;
1✔
60
    reject(err: Error | ResError): void;
1✔
61
    resolve(value: unknown): void;
1✔
62
}
1✔
63

1✔
64
export function getRID(v: unknown): string | null {
1✔
65
    if (typeof v === "object" && v !== null && "getResourceID" in v && typeof v.getResourceID === "function") {
×
66
        return v.getResourceID() as string;
×
67
    }
×
68
    // checking for the rid property causes ResRef to be processed as a regular cacheItem in change events
×
69
    /* if ("rid" in v && typeof v.rid === "string") {
×
70
            return v.rid;
×
71
        } */
×
72

×
73
    return null;
×
74
}
×
75

1✔
76
export interface ResType<K extends string, T extends AnyRes = AnyRes, D = unknown> {
1✔
77
    id: K;
1✔
78
    list: TypeList<T>;
1✔
79
    getFactory(rid: string): ItemFactory<T>;
1✔
80
    prepareData(data: unknown): D;
1✔
81
    synchronize(cacheItem: CacheItem<T>, data: unknown): unknown;
1✔
82
}
1✔
83

1✔
84
export type AnyResType = ResClient["types"][keyof ResClient["types"]];
1✔
85
export default class ResClient {
1✔
86
    private onClose = this._onClose.bind(this);
17✔
87
    private onError = this._onError.bind(this);
17✔
88
    private onMessage = this._onMessage.bind(this);
17✔
89
    private onOpen = this._onOpen.bind(this);
17✔
90
    private unsubscribe = this._unsubscribe.bind(this);
17✔
91
    cache: Record<string, CacheItem> = {};
17✔
92
    connectCallback: { reject(err: ErrorData): void; resolve(): void; } | null = null;
17✔
93
    connectPromise: Promise<void> | null = null;
17✔
94
    connected = false;
17✔
95
    defaultCollectionFactory: ItemFactory<ResCollection>;
17✔
96
    defaultErrorFactory: ItemFactory<ResError>;
17✔
97
    defaultModelFactory: ItemFactory<ResModel>;
17✔
98
    eventBus = eventBus;
17✔
99
    namespace = "resclient";
17✔
100
    onConnect: OnConnectFunction | null = null;
17✔
101
    onConnectError: OnConnectErrorFunction | null = null;
17✔
102
    protocol!: number;
17✔
103
    requestID = 1;
17✔
104
    requests: Record<number, Request> = {};
17✔
105
    retryOnTooActive = false;
17✔
106
    stale: Record<string, boolean> | null = null;
17✔
107
    tryConnect = false;
17✔
108
    types = {
17✔
109
        collection: {
17✔
110
            id:          COLLECTION_TYPE,
17✔
111
            list:        new TypeList((api, rid) => this.defaultCollectionFactory(api, rid)),
17✔
112
            prepareData: (data: Array<unknown>): Array<unknown> => data.map(item => this._prepareValue(item as never, true)),
17✔
113
            getFactory(rid: string): ItemFactory<ResCollection> {
17✔
114
                return this.list.getFactory(rid);
×
115
            },
17✔
116
            synchronize: this._syncCollection.bind(this)
17✔
117
        } satisfies ResType<typeof COLLECTION_TYPE, ResCollection, Array<unknown>> as ResType<typeof COLLECTION_TYPE, ResCollection, Array<unknown>>,
17✔
118
        error: {
17✔
119
            id:          ERROR_TYPE,
17✔
120
            list:        new TypeList((api, rid) => this.defaultErrorFactory(api, rid)),
17✔
121
            prepareData: (data: unknown): unknown => data,
17✔
122
            getFactory(rid: string): ItemFactory<ResError> {
17✔
123
                return this.list.getFactory(rid);
×
124
            },
17✔
125
            // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
17✔
126
            synchronize(cacheItem: CacheItem<ResError>, data: Array<RIDRef>): void {}
17✔
127
        } satisfies ResType<typeof ERROR_TYPE, ResError, unknown> as ResType<typeof ERROR_TYPE, ResError, unknown>,
17✔
128
        model: {
17✔
129
            id:          MODEL_TYPE,
17✔
130
            list:        new TypeList((api, rid) => this.defaultModelFactory(api, rid)),
17✔
131
            prepareData: (data: AnyObject): AnyObject => {
17✔
132
                const obj = {} as AnyObject;
×
133
                // eslint-disable-next-line guard-for-in
×
134
                for (const key of Object.keys(data)) {
×
135
                    obj[key] = this._prepareValue(data[key] as never, true);
×
136
                }
×
137
                return obj;
×
138
            },
17✔
139
            getFactory(rid: string): ItemFactory<ResModel> {
17✔
140
                return this.list.getFactory(rid);
×
141
            },
17✔
142
            synchronize: this._syncModel.bind(this)
17✔
143
        } satisfies ResType<typeof MODEL_TYPE, ResModel, AnyObject> as ResType<typeof MODEL_TYPE, ResModel, AnyObject>
17✔
144
    };
17✔
145
    ws: WebSocket | null = null;
17✔
146
    wsFactory: (() => WebSocket);
17✔
147
    constructor(hostUrlOrFactory: string | (() => WebSocket), options: ClientOptions = {}) {
17✔
148
        this.eventBus = options.eventBus || this.eventBus;
17✔
149
        if (options.eventBus !== undefined) {
17!
150
            this.eventBus = options.eventBus;
×
151
        }
×
152

17✔
153
        if (options.namespace !== undefined) {
17!
154
            this.namespace = options.namespace;
×
155
        }
×
156

17✔
157
        if (options.onConnect !== undefined) {
17!
158
            this.onConnect = options.onConnect;
×
159
        }
×
160

17✔
161
        if (options.onConnectError !== undefined) {
17!
162
            this.onConnectError = options.onConnectError;
×
163
        }
×
164

17✔
165
        if (options.retryOnTooActive !== undefined) {
17!
166
            this.retryOnTooActive = options.retryOnTooActive;
×
167
        }
×
168

17✔
169
        this.defaultCollectionFactory = options.defaultCollectionFactory ?? ((api: ResClient, rid: string): ResCollection => new ResCollection(api, rid));
17✔
170
        this.defaultErrorFactory = options.defaultErrorFactory ?? ((api: ResClient, rid: string): ResError => new ResError(api, rid));
17✔
171
        this.defaultModelFactory = options.defaultModelFactory ?? ((api: ResClient, rid: string): ResModel => new ResModel(api, rid));
17✔
172
        this.wsFactory = typeof hostUrlOrFactory === "string" ? (): WebSocket => new WebSocket(hostUrlOrFactory) : hostUrlOrFactory;
17!
173

17✔
174
        Properties.of(this)
17✔
175
            .readOnlyBulk("cache", "eventBus", "onClose", "onError", "onMessage", "onOpen", "requests", "types", "unsubscribe", "wsFactory")
17✔
176
            .writableBulk("connectCallback", "connected", "connectPromise", "namespace", "onConnect", "onConnectError", "protocol", "requestID", "stale", "tryConnect", "ws");
17✔
177
    }
17✔
178

17✔
179
    private _addStale(rid: string): void {
17✔
180
        if (!this.stale) {
×
181
            this.stale = {};
×
182
        }
×
183
        this.stale[rid] = true;
×
184
    }
×
185

17✔
186
    private async _cacheResources(r: Shared): Promise<void> {
17✔
187
        if (!r || !(r.models || r.collections || r.errors)) {
×
188
            return;
×
189
        }
×
190

×
191
        const sync = {} as Record<typeof RESOURCE_TYPES[number], Record<string, Ref>>;
×
192
        const rr =  (t: typeof RESOURCE_TYPES[number]): Refs => ({ collection: r.collections, model: r.models, error: r.errors }[t]!);
×
193
        // eslint-disable-next-line unicorn/no-array-for-each
×
194
        RESOURCE_TYPES.forEach(t => (sync[t] = this._createItems(rr(t), this.types[t])! as never));
×
195
        // must be initialized in specific order
×
196
        for (const type of RESOURCE_TYPES) {
×
197
            await this._initItems(rr(type), this.types[type]);
×
198
        }
×
199
        // eslint-disable-next-line unicorn/no-array-for-each
×
200
        RESOURCE_TYPES.forEach(t => this._syncItems(sync[t], this.types[t]));
×
201

×
202
    }
×
203

17✔
204
    private async _call<T = unknown>(type: string, rid: string, method?: string, params?: unknown): Promise<T> {
17✔
205
        return this._send<{ payload: T; rid?: string; }>(type, rid, method || "", params)
×
206
            .then(async result => {
×
207
                if (result.rid) {
×
208
                    await this._cacheResources(result as never);
×
209
                    const ci = this.cache[result.rid];
×
210
                    assert(ci, `Missing CacheItem (rid: ${result.rid})`);
×
211
                    ci.addSubscribed(1);
×
212
                    return ci.item as T;
×
213
                }
×
214
                return result.payload;
×
215
            });
×
216
    }
×
217

17✔
218
    private _connectReject(e: ErrorData & { data?: unknown; }): void {
17✔
219
        this.connectPromise = null;
×
220
        this.ws = null;
×
221

×
222
        if (this.connectCallback) {
×
223
            this.connectCallback.reject(e);
×
224
            this.connectCallback = null;
×
225
        }
×
226
    }
×
227

17✔
228
    private _connectResolve(): void {
17✔
229
        if (this.connectCallback) {
×
230
            this.connectCallback.resolve();
×
231
            this.connectCallback = null;
×
232
        }
×
233
    }
×
234

17✔
235
    private _createItems(refs: Refs, type: AnyResType): AnyObject | undefined {
17✔
236
        if (!refs) {
×
237
            return;
×
238
        }
×
239

×
240
        let sync: AnyObject | undefined;
×
241
        for (const rid of Object.keys(refs)) {
×
242
            let ci = this.cache[rid];
×
243
            if (ci) {
×
244
                // Remove item as stale if needed
×
245
                this._removeStale(rid);
×
246
            } else {
×
247
                ci = this.cache[rid] = CacheItem.createDefault(rid, this);
×
248
            }
×
249
            // If an item is already set,
×
250
            // it has gone stale and needs to be synchronized.
×
251
            if (ci.item) {
×
252
                if (ci.type === type.id) {
×
253
                    sync = sync || {};
×
254
                    sync[rid] = refs[rid];
×
255
                } else {
×
256
                    Debug("warn", "Resource type inconsistency", rid, ci.type, type.id);
×
257
                }
×
258
                delete refs[rid];
×
259
            } else {
×
260
                const f = type.getFactory(rid);
×
261
                ci.setItem(f(this, rid), type.id);
×
262
            }
×
263
        }
×
264

×
265
        return sync;
×
266
    }
×
267

17✔
268
    private _deleteRef(ci: CacheItem<AnyRes>): void {
17✔
269
        const item = ci.item;
×
270
        let ri: CacheItem | null = null;
×
271
        switch (ci.type) {
×
272
            case COLLECTION_TYPE: {
×
273
                for (const v of item as ResCollection) {
×
274
                    ri = this._getRefItem(v);
×
275
                    if (ri) {
×
276
                        ri.addIndirect(-1);
×
277
                    }
×
278
                }
×
279
                break;
×
280
            }
×
281
            case MODEL_TYPE: {
×
282
                for (const k in item) {
×
283
                    if (Object.hasOwn(item, k)) {
×
284
                        ri = this._getRefItem(item[k as never]);
×
285
                        if (ri) {
×
286
                            ri.addIndirect(-1);
×
287
                        }
×
288
                    }
×
289
                }
×
290
                break;
×
291
            }
×
292
        }
×
293

×
294
        delete this.cache[ci.rid];
×
295
        this._removeStale(ci.rid);
×
296
    }
×
297

17✔
298
    private _emit(event: string, data: unknown): void {
17✔
299
        this.eventBus.emit(this, event, data, this.namespace);
×
300
    }
×
301

17✔
302
    private _getRefItem(v: unknown): CacheItem | null {
17✔
303
        const rid = getRID(v);
×
304
        if (!rid) {
×
305
            return null;
×
306
        }
×
307
        const refItem = this.cache[rid];
×
308
        // refItem not in cache means
×
309
        // item has been deleted as part of
×
310
        // a refState object.
×
311
        if (!refItem) {
×
312
            return null;
×
313
        }
×
314
        return refItem;
×
315
    }
×
316

17✔
317
    private _getRefState(ci: CacheItem): AnyObject<Ref> {
17✔
318
        const refs = {} as AnyObject<Ref>;
17✔
319
        // Quick exit
17✔
320
        if (ci.subscribed) {
17✔
321
            return refs;
1✔
322
        }
1✔
323
        refs[ci.rid] = { ci, rc: ci.indirect, st: States.NONE };
16✔
324
        this._traverse(ci, this._seekRefs.bind(this, refs), 0, true);
16✔
325
        this._traverse(ci, this._markDelete.bind(this, refs) as never, States.DELETE);
16✔
326
        return refs;
16✔
327
    }
16✔
328

17✔
329
    private async _handleAddEvent(ci: CacheItem<ResCollection>, event: string, data: AddEventData): Promise<boolean> {
17✔
330
        if (ci.type !== COLLECTION_TYPE) {
×
331
            return false;
×
332
        }
×
333

×
334
        await this._cacheResources(data);
×
335
        const v = this._prepareValue(data.value, true);
×
336
        const idx = data.idx;
×
337

×
338
        ci.item.add(v, idx);
×
339
        this.eventBus.emit(ci.item, `${this.namespace}.resource.${ci.rid}.${event}`, { item: v, idx });
×
340
        return true;
×
341
    }
×
342

17✔
343
    private async _handleChangeEvent(cacheItem: CacheItem<ResModel>, event: string, data: ChangeEventData, reset: boolean): Promise<boolean> {
17✔
344
        if (cacheItem.type !== MODEL_TYPE) {
×
345
            return false;
×
346
        }
×
347

×
348
        await this._cacheResources(data);
×
349

×
350
        const item = cacheItem.item;
×
351
        let rid;
×
352
        const vals = data.values;
×
353
        for (const key of Object.keys(vals)) {
×
354
            vals[key] = this._prepareValue(vals[key]!) as string;
×
355
        }
×
356

×
357
        // Update the model with new values
×
358
        const changed = item.update(vals, reset);
×
359
        if (!changed) {
×
360
            return false;
×
361
        }
×
362

×
363
        // Used changed object to determine which resource references has been
×
364
        // added or removed.
×
365
        const ind: Record<string, number> = {};
×
366
        for (const key of Object.keys(changed)) {
×
367
            if ((rid = getRID(changed[key]))) {
×
368
                ind[rid] = (ind[rid] || 0) - 1;
×
369
            }
×
370
            if ((rid = getRID(vals[key]))) {
×
371
                ind[rid] = (ind[rid] || 0) + 1;
×
372
            }
×
373
        }
×
374

×
375
        // Remove indirect reference to resources no longer referenced in the model
×
376
        for (const [key, value] of Object.entries(ind)) {
×
377
            const ci = this.cache[key];
×
378
            assert(ci, `Missing CacheItem (rid: ${key})`);
×
379
            ci.addIndirect(value);
×
380
            if (value > 0) {
×
381
                this._tryDelete(ci);
×
382
            }
×
383
        }
×
384

×
385
        this.eventBus.emit(cacheItem.item, `${this.namespace}.resource.${cacheItem.rid}.${event}`, changed);
×
386
        return true;
×
387
    }
×
388

17✔
389
    private async _handleErrorResponse(req: Request, data: unknown): Promise<void> {
17✔
390
        const m = req.method;
×
391
        // Extract the rid if possible
×
392
        let rid = "";
×
393
        let i = m.indexOf(".");
×
394
        if (i >= 0) {
×
395
            rid = m.slice(i + 1);
×
396
            const a = m.slice(0, Math.max(0, i));
×
397
            if (a === "call" || a === "auth") {
×
398
                i = rid.lastIndexOf(".");
×
399
                if (i >= 0) {
×
400
                    rid = rid.slice(0, Math.max(0, i));
×
401
                }
×
402
            }
×
403
        }
×
404
        const errorData = (data as Record<"error", ErrorData & { data?: unknown; }>).error;
×
405
        if (this.retryOnTooActive && "code" in errorData && errorData.code === ErrorCodes.TOO_ACTIVE) {
×
406
            const seconds = (Number((errorData.data as { seconds: number; } | undefined)?.seconds) || 0) + 1;
×
407
            Debug("tooActive", `Got ${ErrorCodes.TOO_ACTIVE}, waiting ${seconds} second${seconds === 1 ? "" : "s"} to retry ${req.method}`);
×
408
            await new Promise(resolve => setTimeout(resolve, seconds * 1000)); // can't import timers/promises due to other non-async usages
×
409
            const r = await this._sendNow(req.method, req.params);
×
410
            req.resolve(r);
×
411
            return;
×
412
        } else {
×
413
            const err = await (new ResError(this, rid.trim(), m, req.params)).init(errorData);
×
414
            try {
×
415
                this._emit("error", err);
×
416
            } catch {}
×
417

×
418
            // Execute error callback bound to calling object
×
419
            req.reject(err);
×
420
        }
×
421
    }
×
422

17✔
423
    private async _handleEvent(data: { data: unknown; event: string; }): Promise<void> {
17✔
424
        // Event
×
425
        const index = data.event.lastIndexOf(".");
×
426
        if (index === -1 || index === data.event.length - 1) {
×
427
            throw new Error(`Malformed event name: ${data.event}`);
×
428
        }
×
429

×
430
        const rid = data.event.slice(0, Math.max(0, index));
×
431

×
432
        const cacheItem = this.cache[rid];
×
433
        if (!cacheItem?.item) {
×
434
            throw new Error("Resource not found in cache");
×
435
        }
×
436

×
437
        const event = data.event.slice(index + 1);
×
438
        let handled = false;
×
439
        switch (event) {
×
440
            case "change": {
×
441
                handled = await this._handleChangeEvent(cacheItem as CacheItem<ResModel>, event, data.data as ChangeEventData, false);
×
442
                break;
×
443
            }
×
444

×
445
            case "add": {
×
446
                handled = await this._handleAddEvent(cacheItem as CacheItem<ResCollection>, event, data.data as AddEventData);
×
447
                break;
×
448
            }
×
449

×
450
            case "remove": {
×
451
                handled = await this._handleRemoveEvent(cacheItem as CacheItem<ResCollection>, event, data.data as RemoveEventData);
×
452
                break;
×
453
            }
×
454

×
455
            case "unsubscribe": {
×
456
                handled = await this._handleUnsubscribeEvent(cacheItem);
×
457
                break;
×
458
            }
×
459
        }
×
460

×
461
        if (!handled) {
×
462
            this.eventBus.emit(cacheItem.item, `${this.namespace}.resource.${rid}.${event}`, data.data);
×
463
        }
×
464
    }
×
465

17✔
466
    private _handleFailedSubscribe(ci: CacheItem): void {
17✔
467
        ci.addSubscribed(-1);
×
468
        this._tryDelete(ci);
×
469
    }
×
470

17✔
471
    private async _handleRemoveEvent(ci: CacheItem<ResCollection>, event: string, data: RemoveEventData): Promise<boolean> {
17✔
472
        if (ci.type !== COLLECTION_TYPE) {
×
473
            return false;
×
474
        }
×
475

×
476
        const idx = data.idx;
×
477
        const item = ci.item.remove(idx);
×
478
        this.eventBus.emit(ci.item, `${this.namespace}.resource.${ci.rid}.${event}`, { item, idx });
×
479

×
480
        const rid = getRID(item);
×
481
        if (rid) {
×
482
            const refItem = this.cache[rid];
×
483
            if (!refItem) {
×
484
                throw new Error("Removed model is not in cache");
×
485
            }
×
486

×
487
            refItem.addIndirect(-1);
×
488
            this._tryDelete(refItem);
×
489
        }
×
490
        return true;
×
491
    }
×
492

17✔
493
    private async _handleSuccessResponse(req: Request, data: unknown): Promise<void> {
17✔
494
        req.resolve((data as Record<"result", unknown>).result);
×
495
    }
×
496

17✔
497
    private async _handleUnsubscribeEvent(ci: CacheItem): Promise<boolean> {
17✔
498
        await ci.item.dispose();
×
499
        ci.addSubscribed(0);
×
500
        this._tryDelete(ci);
×
501
        this.eventBus.emit(ci.item, `${this.namespace}.resource.${ci.rid}.unsubscribe`, { item: ci.item });
×
502
        return true;
×
503
    }
×
504

17✔
505
    private async _initItems(refs: Refs, type: AnyResType): Promise<void> {
17✔
506
        if (!refs) {
×
507
            return;
×
508
        }
×
509

×
510
        const promises: Array<Promise<AnyRes>> = [];
×
511
        for (const rid of Object.keys(refs)) {
×
512
            const cacheItem = this.cache[rid];
×
513
            assert(cacheItem, `Missing CacheItem (rid: ${rid})`);
×
514
            promises.push(cacheItem.item.init(type.prepareData(refs[rid] as never) as never));
×
515
        }
×
516
        await Promise.all(promises);
×
517
    }
×
518

17✔
519
    // @FIXME: this is a mess
17✔
520
    private _markDelete(refs: Record<string, Ref>, ci: CacheItem, state: unknown): unknown {
17✔
521
        // Quick exit if it is already subscribed
61✔
522
        if (ci.subscribed) {
61!
523
            return false;
×
524
        }
×
525

61✔
526
        const rid = ci.rid;
61✔
527
        const r = refs[rid]!;
61✔
528

61✔
529
        if (r.st === States.KEEP) {
61✔
530
            return false;
8✔
531
        }
8✔
532

53✔
533
        if (state === States.DELETE) {
61✔
534

38✔
535
            if (r.rc > 0) {
38✔
536
                r.st = States.KEEP;
7✔
537
                return rid;
7✔
538
            }
7✔
539

31✔
540
            if (r.st !== States.NONE) {
38✔
541
                return false;
2✔
542
            }
2✔
543

29✔
544
            if (r.ci.direct) {
38✔
545
                r.st = States.STALE;
5✔
546
                return rid;
5✔
547
            }
5✔
548

24✔
549
            r.st = States.DELETE;
24✔
550
            return States.DELETE;
24✔
551
        }
24✔
552

15✔
553
        // A stale item can never cover itself
15✔
554
        if (rid === state) {
61✔
555
            return false;
2✔
556
        }
2✔
557

13✔
558
        r.st = States.KEEP;
13✔
559
        return r.rc > 0 ? rid : state;
61!
560
    }
61✔
561

17✔
562
    private async _onClose(e: unknown): Promise<void> {
17✔
563
        if (typeof e === "object" && e !== null) {
×
564
            if ("message" in e) {
×
565
                Debug("ws", "ResClient close", ...[e.message, (e as { code?: string; }).code].filter(Boolean));
×
566
            } else if ("code" in e) {
×
567
                Debug("ws", "ResClient close", e.code);
×
568
            } else {
×
569
                Debug("ws", "ResClient close", e);
×
570
            }
×
571
        }
×
572
        this.connectPromise = null;
×
573
        this.ws = null;
×
574
        const wasConnected = this.connected;
×
575
        if (this.connected) {
×
576
            this.connected = false;
×
577

×
578
            // Set any subscribed item in cache to stale
×
579
            for (const rid of Object.keys(this.cache)) {
×
580
                const ci = this.cache[rid];
×
581
                assert(ci, `Missing CacheItem (rid: ${rid})`);
×
582
                if (ci.subscribed) {
×
583
                    ci.addSubscribed(0);
×
584
                    this._addStale(rid);
×
585
                    this._tryDelete(ci);
×
586
                }
×
587
            }
×
588

×
589
            this._emit("disconnect", e);
×
590
        }
×
591

×
592
        let hasStale = false;
×
593

×
594
        if (Object.keys(this.cache).length !== 0) {
×
595
            hasStale = true;
×
596
        }
×
597

×
598
        this.tryConnect = hasStale && this.tryConnect;
×
599

×
600
        if (this.tryConnect) {
×
601
            await this._reconnect(wasConnected);
×
602
        }
×
603
    }
×
604

17✔
605
    private async _onError(e: unknown): Promise<void> {
17✔
606
        Debug("ws", "ResClient error", e);
×
607
        this._connectReject({ code: ErrorCodes.CONNECTION_ERROR, message: "Connection error", data: e });
×
608
    }
×
609

17✔
610
    private async _onMessage(e: MessageEvent): Promise<void> {
17✔
611
        await this._receive((e as { data: string; }).data);
×
612
    }
×
613

17✔
614
    private async _onOpen(e: unknown): Promise<void> {
17✔
615
        Debug("ws", "ResClient open");
×
616
        let onConnectError: unknown = null;
×
617
        await this._sendNow<{ protocol: string; }>("version", { protocol: this.supportedProtocol })
×
618
            .then(ver=> {
×
619
                this.protocol = versionToInt(ver.protocol) || LEGACY_PROTOCOL;
×
620
            })
×
621
            .catch((err: ResError) => {
×
622
                // Invalid error means the gateway doesn't support
×
623
                // version requests. Default to legacy protocol.
×
624
                if (err.code && err.code === ErrorCodes.INVALID_REQUEST) {
×
625
                    this.protocol = LEGACY_PROTOCOL;
×
626
                    return;
×
627
                }
×
628
                throw err;
×
629
            })
×
630
            .then(async() => {
×
631
                if (this.onConnect) {
×
632
                    this.connected = true;
×
633
                    await ensurePromiseReturn(this.onConnect, null, this)
×
634
                        .catch(async(err: unknown) => {
×
635
                            if (this.onConnectError === null) {
×
636
                                onConnectError = err;
×
637
                            } else {
×
638
                                await ensurePromiseReturn(this.onConnectError, null, this, err)
×
639
                                    .then(() => {
×
640
                                        onConnectError = null;
×
641
                                    })
×
642
                                    .catch((onerr: unknown) => {
×
643
                                        onConnectError = onerr;
×
644
                                    });
×
645
                            }
×
646
                        });
×
647
                    this.connected = false;
×
648
                }
×
649
            })
×
650
            .then(async() => {
×
651
                this.connected = true;
×
652
                await this._subscribeToAllStale();
×
653
                this._emit("connect", e);
×
654
                this._connectResolve();
×
655
            })
×
656
            .catch(() => this.ws?.close())
×
657
            .then(() => {
×
658
                if (onConnectError !== null) {
×
659
                    throw onConnectError;
×
660
                }
×
661
            });
×
662
    }
×
663

17✔
664
    private async _patchDiff<T>(a: Array<T>, b: Array<T>, onKeep: (item: T, aIndex: number, bIndex: number, idx: number) => Promise<unknown>, onAdd: (item: T, aIndex: number, bIndex: number) => Promise<unknown>, onRemove: (item: T, aIndex: number, idx: number) => Promise<unknown>): Promise<void> {
17✔
665
        return lcsDiffAsync<T>(a, b, onKeep, onAdd, onRemove);
×
666
    }
×
667

17✔
668
    private _prepareValue(v: { action?: string; data?: unknown; rid?: string; soft?: boolean; } | string, addIndirect = false): unknown {
17✔
669
        let val: unknown = v;
×
670
        if (v !== null && typeof v === "object") {
×
671
            if (v.rid) {
×
672
                // Resource reference
×
673
                if (v.soft) {
×
674
                    // Soft reference
×
675
                    val = new ResRef(this, v.rid);
×
676
                } else {
×
677
                    // Non-soft reference
×
678
                    const ci = this.cache[v.rid];
×
679
                    assert(ci, `Missing CacheItem (rid: ${v.rid})`);
×
680
                    if (addIndirect) {
×
681
                        ci.addIndirect();
×
682
                    }
×
683
                    val = ci.item;
×
684
                }
×
685
            } else if (Object.hasOwn(v, "data")) {
×
686
                // Data value
×
687
                val = v.data;
×
688
            } else if (v.action === "delete") {
×
689
                val = undefined;
×
690
            } else {
×
691
                throw new Error("Invalid value: " + JSON.stringify(val));
×
692
            }
×
693
        }
×
694

×
695
        return val;
×
696
    }
×
697

17✔
698
    private async _receive(json: string): Promise<void> {
17✔
699
        const data = JSON.parse(json.trim()) as AnyObject;
×
700
        Debug("ws:receive", "<-", data);
×
701

×
702
        if (Object.hasOwn(data, "id")) {
×
703
            const id = data.id as number;
×
704

×
705
            // Find the stored request
×
706
            const req = this.requests[id];
×
707
            if (!req) {
×
708
                throw new Error("Server response without matching request");
×
709
            }
×
710

×
711
            delete this.requests[id];
×
712

×
713
            if (Object.hasOwn(data, "error")) {
×
714
                await this._handleErrorResponse(req, data);
×
715
            } else {
×
716
                await this._handleSuccessResponse(req, data);
×
717
            }
×
718
        } else if (Object.hasOwn(data, "event")) {
×
719
            await this._handleEvent(data as never);
×
720
        } else {
×
721
            throw new Error("Invalid message from server: " + json);
×
722
        }
×
723
    }
×
724

17✔
725
    private async _reconnect(noDelay = false): Promise<void> {
17✔
726
        if (noDelay) {
×
727
            await this.connect();
×
728
            return;
×
729
        }
×
730
        setTimeout(async() => {
×
731
            if (!this.tryConnect) {
×
732
                return;
×
733
            }
×
734

×
735
            await this.connect();
×
736
        }, RECONNECT_DELAY);
×
737
    }
×
738

17✔
739
    private _removeStale(rid: string): void {
17✔
740
        if (this.stale) {
×
741
            delete this.stale[rid];
×
742
            if (Object.keys(this.stale).length === 0) {
×
743
                this.stale = null;
×
744
            }
×
745
        }
×
746
    }
×
747

17✔
748
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
17✔
749
    private _seekRefs(refs: Record<string, Ref>, ci: CacheItem, state: States): boolean {
17✔
750
        // Quick exit if it is already subscribed
39✔
751
        if (ci.subscribed) {
39!
752
            return false;
×
753
        }
×
754

39✔
755
        const rid = ci.rid;
39✔
756
        const r = refs[rid];
39✔
757
        if (r) {
39✔
758
            r.rc--;
11✔
759
            return false;
11✔
760
        }
11✔
761

28✔
762
        refs[rid] = { ci, rc: ci.indirect - 1, st: States.NONE };
28✔
763
        return true;
28✔
764
    }
28✔
765

17✔
766
    private _send<T = unknown>(action: string, rid: string, method?: string, params?: unknown): Promise<T> {
17✔
767
        if (!rid) {
×
768
            throw new Error("Invalid resource ID");
×
769
        }
×
770

×
771
        if (method === "") {
×
772
            throw new Error("Invalid method");
×
773
        }
×
774

×
775
        const m = `${action}.${rid}${(method ? `.${method}` : "")}`;
×
776

×
777
        return this.connected
×
778
            ? this._sendNow<T>(m, params)
×
779
            : this.connect()
×
780
                .catch(async err => {
×
781
                    throw (await (new ResError(this, rid, m, params)).init(err as Error));
×
782
                })
×
783
                .then(() => this._sendNow<T>(m, params));
×
784
    }
×
785

17✔
786
    private _sendNow<T = unknown>(method: string, params?: unknown): Promise<T> {
17✔
787
        return new Promise<T>((resolve, reject) => {
×
788
            const req = { id: this.requestID++, method, params };
×
789

×
790
            this.requests[req.id] = {
×
791
                method,
×
792
                params: req.params,
×
793
                resolve,
×
794
                reject
×
795
            };
×
796

×
797
            const json = JSON.stringify(req);
×
798
            Debug("ws:send", "->", method, params);
×
799
            this.ws!.send(json);
×
800
        });
×
801
    }
×
802

17✔
803
    private _sendUnsubscribe(ci: CacheItem, count: number): void {
17✔
804
        this._send("unsubscribe", ci.rid, undefined, count > 1 ? { count } : null)
×
805
            .then(() => {
×
806
                ci.addSubscribed(-count);
×
807
                this._tryDelete(ci);
×
808
            })
×
809
            .catch(() => this._tryDelete(ci));
×
810
    }
×
811

17✔
812
    private _setStale(rid: string): void {
17✔
813
        this._addStale(rid);
×
814
        if (this.connected) {
×
815
            setTimeout(() => this._subscribeToStale(rid), SUBSCRIBE_STALE_DELAY);
×
816
        }
×
817
    }
×
818

17✔
819
    private async _subscribe(ci: CacheItem, throwError = false): Promise<void> {
17✔
820
        const rid = ci.rid;
×
821
        ci.addSubscribed(1);
×
822
        this._removeStale(rid);
×
823
        return this._send<Shared>("subscribe", rid)
×
824
            .then(response => this._cacheResources(response))
×
825
            .catch(async err => {
×
826
                if (throwError) {
×
827
                    this._handleFailedSubscribe(ci);
×
828
                    throw err;
×
829
                } else {
×
830
                    await this._handleUnsubscribeEvent(ci);
×
831
                }
×
832
            });
×
833
    }
×
834

17✔
835
    private async _subscribeReferred(ci: CacheItem): Promise<void> {
17✔
836
        const i = ci.subscribed;
×
837
        ci.subscribed = 0;
×
838
        const refs = this._getRefState(ci);
×
839
        ci.subscribed = i;
×
840

×
841
        for (const rid of Object.keys(refs)) {
×
842
            const r = refs[rid]!;
×
843
            if (r.st === States.STALE) {
×
844
                await this._subscribe(r.ci);
×
845
            }
×
846
        }
×
847
    }
×
848

17✔
849
    private async _subscribeToAllStale(): Promise<void> {
17✔
850
        if (!this.stale) {
×
851
            return;
×
852
        }
×
853

×
854
        for (const rid of Object.keys(this.stale)) {
×
855
            await this._subscribeToStale(rid);
×
856
        }
×
857
    }
×
858

17✔
859
    private async _subscribeToStale(rid: string): Promise<void> {
17✔
860
        if (!this.connected || !this.stale || !this.stale[rid]) {
×
861
            return;
×
862
        }
×
863

×
864
        const ci = this.cache[rid];
×
865
        assert(ci, `Missing CacheItem (rid: ${rid})`);
×
866
        return this._subscribe(ci);
×
867
    }
×
868

17✔
869
    private async _syncCollection(cacheItem: CacheItem<ResCollection>, data: Array<RIDRef>): Promise<void> {
17✔
870
        const collection = cacheItem.item;
×
871
        let i = collection.length;
×
872
        const a = Array.from({ length: i });
×
873
        while (i--) {
×
874
            a[i] = collection.at(i);
×
875
        }
×
876

×
877
        const b = data.map(v => this._prepareValue(v as never));
×
878
        await this._patchDiff<unknown>(a, b,
×
879
            async () => {},
×
880
            async (id: unknown, n: number, idx: number) => this._handleAddEvent(cacheItem, "add", { value: data[n]!, idx }),
×
881
            async (id: unknown, m: number, idx: number) => this._handleRemoveEvent(cacheItem, "remove", { idx })
×
882
        );
×
883
    }
×
884

17✔
885
    private async _syncItems(refs: AnyObject, type: AnyResType): Promise<void> {
17✔
886
        if (!refs) {
×
887
            return;
×
888
        }
×
889

×
890
        for (const rid of Object.keys((refs))) {
×
891
            const ci = this.cache[rid];
×
892
            await type.synchronize(ci as never, refs[rid] as never);
×
893
        }
×
894
    }
×
895

17✔
896
    private async _syncModel(ci: CacheItem<ResModel>, data: ChangeEventData["values"]): Promise<void> {
17✔
897
        await this._handleChangeEvent(ci, "change", { values: data }, true);
×
898
    }
×
899

17✔
900
    private _traverse(ci: CacheItem, cb: (ci: CacheItem, state: States) => States | boolean, state: States, skipFirst = false): void {
17✔
901
        // Call callback to get new state to pass to
116✔
902
        // children. If false, we should not traverse deeper
116✔
903
        if (!skipFirst) {
116✔
904
            const s = cb(ci, state);
100✔
905
            if (s === false) {
100✔
906
                return;
23✔
907
            } else {
100✔
908
                state = s as States;
77✔
909
            }
77✔
910
        }
100✔
911

93✔
912
        const item = ci.item;
93✔
913
        switch (ci.type) {
93✔
914
            case COLLECTION_TYPE: {
93✔
915
                for (const v of item as ResCollection) {
93✔
916
                    const cii = this._getRefItem(v);
84✔
917
                    if (cii) {
84✔
918
                        this._traverse(cii, cb, state);
84✔
919
                    }
84✔
920
                }
84✔
921
                break;
93✔
922
            }
93✔
923
            case MODEL_TYPE: {
116!
924
                for (const k in item) {
×
925
                    if (Object.hasOwn(item, k)) {
×
926
                        const cii = this._getRefItem(item[k as never]);
×
927
                        if (cii) {
×
928
                            this._traverse(cii, cb, state);
×
929
                        }
×
930
                    }
×
931
                }
×
932
                break;
×
933
            }
×
934
        }
116✔
935
    }
116✔
936

17✔
937
    private _tryDelete(ci: CacheItem): void {
17✔
938
        const refs = this._getRefState(ci);
×
939

×
940
        for (const rid of Object.keys(refs)) {
×
941
            const r = refs[rid]!;
×
942
            switch (r.st) {
×
943
                case States.STALE: {
×
944
                    this._setStale(rid);
×
945
                    break;
×
946
                }
×
947
                case States.DELETE: {
×
948
                    this._deleteRef(r.ci as never);
×
949
                    break;
×
950
                }
×
951
            }
×
952
        }
×
953
    }
×
954

17✔
955
    private async _unsubscribe(ci: CacheItem): Promise<void> {
17✔
956
        if (!ci.subscribed) {
×
957
            if (this.stale && this.stale[ci.rid]) {
×
958
                this._tryDelete(ci);
×
959
            }
×
960
            return;
×
961
        }
×
962

×
963
        await this._subscribeReferred(ci);
×
964

×
965
        let i = ci.subscribed;
×
966
        if (this.protocol < CURRENT_PROTOCOL) {
×
967
            while (i--) {
×
968
                this._sendUnsubscribe(ci, 1);
×
969
            }
×
970
        } else {
×
971
            this._sendUnsubscribe(ci, i);
×
972
        }
×
973
    }
×
974

17✔
975
    get supportedProtocol(): string {
17✔
976
        return SUPPORTED_PROTOCOL;
×
977
    }
×
978

17✔
979
    authenticate<T = unknown>(rid: string, method: string, params: unknown): Promise<T> {
17✔
980
        return this._call<T>("auth", rid, method, params);
×
981
    }
×
982

17✔
983
    call<T = unknown>(rid: string, method: string, params?: unknown): Promise<T> {
17✔
984
        return this._call<T>("call", rid, method, params);
×
985
    }
×
986

17✔
987
    connect(): Promise<void> {
17✔
988
        this.tryConnect = true;
×
989
        if (!this.connectPromise) {
×
990
            this.connectPromise = new Promise<void>((resolve, reject) => {
×
991
                this.connectCallback = { resolve, reject };
×
992
                this.ws = this.wsFactory();
×
993

×
994
                /* eslint-disable unicorn/prefer-add-event-listener */
×
995
                this.ws.onopen = this.onOpen;
×
996
                this.ws.onerror = this.onError;
×
997
                this.ws.onmessage = this.onMessage;
×
998
                this.ws.onclose = this.onClose;
×
999
                /* eslint-enable unicorn/prefer-add-event-listener */
×
1000
            });
×
1001
            this.connectPromise.catch(err => this._emit("connectError", err));
×
1002
        }
×
1003

×
1004
        return this.connectPromise;
×
1005
    }
×
1006

17✔
1007
    async create(rid: string, params: unknown): Promise<ResModel | ResError | ResCollection> {
17✔
1008
        return this._send<Shared>("new", rid, undefined, params)
×
1009
            .then(async result => {
×
1010
                await this._cacheResources(result);
×
1011
                const _rid = (result as { rid: string; }).rid;
×
1012
                const ci = this.cache[rid];
×
1013
                assert(ci, `Missing CacheItem (rid: ${_rid})`);
×
1014
                ci.addSubscribed(1);
×
1015
                return ci.item;
×
1016
            });
×
1017
    }
×
1018

17✔
1019
    async disconnect(): Promise<void> {
17✔
1020
        this.tryConnect = false;
×
1021

×
1022
        if (this.ws) {
×
1023
            const ws = this.ws;
×
1024
            const err = { code: ErrorCodes.DISCONNECT, message: "Disconnect called" };
×
1025
            ws.removeEventListener("close", this.onClose);
×
1026
            await this.onClose(err);
×
1027
            ws.close();
×
1028
            this._connectReject(err);
×
1029
        }
×
1030
    }
×
1031

17✔
1032
    async get<T extends AnyRes = AnyRes>(rid: string, forceKeep = false): Promise<T> {
17✔
1033
        Debug("client:get", `${rid}${forceKeep ? " (keep)" : ""}`);
×
1034
        return this.subscribe(rid, forceKeep).then(() => this.getCached<T>(rid)!);
×
1035
    }
×
1036

17✔
1037
    getCached<T extends AnyRes = AnyRes>(rid: string): T | null {
17✔
1038
        Debug("client:getCached", rid);
×
1039
        return this.cache[rid]?.item as T ?? null;
×
1040
    }
×
1041

17✔
1042
    async getPaginated<T extends ResModel = ResModel>(rid: string, offset: number, limit: number): Promise<Array<T>> {
17✔
1043
        rid = `${rid}?offset=${offset}&limit=${limit}`;
×
1044
        Debug("client:getPaginated", rid);
×
1045
        const ci = CacheItem.createDefault(rid, this);
×
1046
        this.cache[rid] = ci;
×
1047
        await ci.setPromise(this._subscribe(ci, true));
×
1048
        const item = ci.item as unknown as ResCollection | ResModel;
×
1049
        let items: Array<T>;
×
1050
        if (item instanceof ResModel) {
×
1051
            items = Object.values(item.props as Record<string, T>);
×
1052
        } else if (item instanceof ResCollection) {
×
1053
            items = item.list as Array<T>;
×
1054
        } else {
×
1055
            assert(false, `Invalid resource type for paginated request: ${(item as AnyClass).constructor.name}`);
×
1056
        }
×
1057
        ci.unsubscribe();
×
1058
        return items;
×
1059
    }
×
1060

17✔
1061
    keepCached(item: CacheItem, cb = false): void {
17✔
1062
        Debug("client:keepCached", item.rid);
×
1063
        if (item.forceKeep) return;
×
1064
        if (!cb) item.keep();
×
1065
        item.resetTimeout();
×
1066
    }
×
1067

17✔
1068
    off(handler: AnyFunction): this;
17✔
1069
    off(events: string | Array<string> | null, handler: AnyFunction): this;
17✔
1070
    off(...args: [string | Array<string> | null, AnyFunction] | [AnyFunction]): this {
17✔
1071
        this.eventBus.off(this, args.length === 1 ? null : args[0], args.at(-1) as AnyFunction, this.namespace);
×
1072
        return this;
×
1073
    }
×
1074

17✔
1075
    on(handler: AnyFunction): this;
17✔
1076
    on(events: string | Array<string> | null, handler: AnyFunction): this;
17✔
1077
    on(...args: [string | Array<string> | null, AnyFunction] | [AnyFunction]): this {
17✔
1078
        this.eventBus.on(this, args.length === 1 ? null : args[0], args.at(-1) as AnyFunction, this.namespace);
×
1079
        return this;
×
1080
    }
×
1081

17✔
1082
    registerCollectionType(pattern: string, factory: ItemFactory<ResCollection>): this {
17✔
1083
        this.types.collection.list.addFactory(pattern, factory);
×
1084
        return this;
×
1085
    }
×
1086

17✔
1087
    registerModelType(pattern: string, factory: ItemFactory<ResModel>): this {
17✔
1088
        this.types.model.list.addFactory(pattern, factory);
×
1089
        return this;
×
1090
    }
×
1091

17✔
1092
    resourceOff(rid: string, events: string | Array<string> | null, handler: AnyFunction): void {
17✔
1093
        Debug("client:resourceOff", `${rid} ${events === null ? "all" : (Array.isArray(events) ? events.join(", ") : events)}`);
×
1094
        const cacheItem = this.cache[rid];
×
1095
        if (!cacheItem?.item) {
×
1096
            throw new Error(`Resource ${rid} not found in cache`);
×
1097
        }
×
1098

×
1099
        cacheItem.removeDirect();
×
1100
        this.eventBus.off(cacheItem.item, events, handler, `${this.namespace}.resource.${rid}`);
×
1101
    }
×
1102

17✔
1103
    resourceOn(rid: string, events: string | Array<string> | null, handler: AnyFunction): void {
17✔
1104
        Debug("client:resourceOn", `${rid} ${events === null ? "all" : (Array.isArray(events) ? events.join(", ") : events)}`);
×
1105
        const cacheItem = this.cache[rid];
×
1106
        if (!cacheItem?.item) {
×
1107
            throw new Error(`Resource ${rid} not found in cache`);
×
1108
        }
×
1109

×
1110
        cacheItem.addDirect();
×
1111
        this.eventBus.on(cacheItem.item, events, handler, `${this.namespace}.resource.${rid}`);
×
1112
    }
×
1113

17✔
1114
    // TODO: needs better typing
17✔
1115
    setModel(modelId: string, props: AnyObject): Promise<unknown> {
17✔
1116
        props = { ...props };
×
1117
        // Replace undefined with actionDelete object
×
1118
        for (const k of Object.keys(props)) {
×
1119
            if (props[k] === undefined) {
×
1120
                props[k] = ACTION_DELETE;
×
1121
            }
×
1122
        }
×
1123

×
1124
        return this._send("call", modelId, "set", props);
×
1125
    }
×
1126

17✔
1127
    setOnConnect(onConnect: OnConnectFunction | null, onConnectError?: OnConnectErrorFunction | null): this {
17✔
1128
        this.onConnect = onConnect;
×
1129
        if (onConnectError !== undefined) {
×
1130
            this.onConnectError = onConnectError;
×
1131
        }
×
1132
        return this;
×
1133
    }
×
1134

17✔
1135
    async subscribe(rid: string, forceKeep = false): Promise<void> {
17✔
1136
        Debug("client:subscribe", `${rid}${forceKeep ? " (keep)" : ""}`);
×
1137
        let ci = this.cache[rid];
×
1138
        if (ci) {
×
1139
            if (ci.promise) await ci.promise;
×
1140
            ci.resetTimeout();
×
1141
            if (forceKeep) ci.keep();
×
1142
            return;
×
1143
        }
×
1144

×
1145
        ci = CacheItem.createDefault(rid, this);
×
1146
        this.cache[rid] = ci;
×
1147
        if (forceKeep) ci.keep();
×
1148
        return ci.setPromise(this._subscribe(ci, true));
×
1149
    }
×
1150

17✔
1151
    unkeepCached(item: CacheItem, cb = false): void {
17✔
1152
        Debug("client:unkeepCached", item.rid);
×
1153
        if (!item.forceKeep) return;
×
1154
        if (!cb) item.unkeep();
×
1155
        item.resetTimeout();
×
1156
    }
×
1157

17✔
1158
    unregisterCollectionType(pattern: string): this {
17✔
1159
        this.types.collection.list.removeFactory(pattern);
×
1160
        return this;
×
1161
    }
×
1162

17✔
1163
    unregisterModelType(pattern: string): this {
17✔
1164
        this.types.model.list.removeFactory(pattern);
×
1165
        return this;
×
1166
    }
×
1167
}
17✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc