vanilla-party
Version:
An easy to use library for simple multi-user JavaScript apps.
237 lines (197 loc) • 6.66 kB
text/typescript
import onChange from "on-change"
import { DeepstreamClient } from "@deepstream/client"
import { CONNECTION_STATE } from "@deepstream/client/dist/src/constants"
import { Record as DSRecord } from "@deepstream/client/dist/src/record/record"
import * as log from "./log"
import { SubscriptionCallback } from "./validate"
import { UserData, JSONValue, JSONObject, RecordData } from "./validate"
import { isJSONValue, isJSONObject, isEmpty } from "./validate"
import { patchInPlace } from "./patch"
type SharedObject = JSONObject
const sharedRecordLookup = new WeakMap<SharedObject, Record>()
export class Record {
readonly #ds: DeepstreamClient
readonly #name: string
#dsRecord: DSRecord | null
#shared: SharedObject
#watchedShared: SharedObject
#whenLoaded: Promise<void> | null
constructor(ds: DeepstreamClient, name: string) {
this.#ds = ds
this.#name = name
this.#dsRecord = null
this.#shared = {}
this.#watchedShared = onChange(
this.#shared,
this.#onClientChangeData.bind(this),
{
onValidate: this.#onClientValidateData.bind(this),
}
)
this.#whenLoaded = null
sharedRecordLookup.set(this.#shared, this)
}
async load(initObject?: UserData, overwrite = false): Promise<void> {
if (this.#whenLoaded) {
log.warn("Record.load() called twice!", this.#name)
return this.#whenLoaded
}
if (this.#ds.getConnectionState() !== CONNECTION_STATE.OPEN) {
// "OPEN"
log.error("Record.load() called before room is connected.", this.#name)
return
}
const innerLoad = async () => {
this.#dsRecord = this.#ds.record.getRecord(this.#name)
this.#dsRecord.subscribe(this.#onServerChangeData.bind(this), true)
await this.#dsRecord.whenReady()
if (!initObject) return
await this.initData(initObject, overwrite)
}
this.#whenLoaded = innerLoad()
return this.#whenLoaded
}
get whenLoaded(): Promise<void> {
if (this.#whenLoaded === null) {
log.error("Record.whenLoaded called before load().", this.#name)
return Promise.reject(
new Error("Record.whenLoaded called before load().")
)
}
return this.#whenLoaded
}
/**
* sets initial data on the record only if the record is empty
*
* @param data initial data to set on the record
*/
async initData(data: UserData, overwrite = false): Promise<void> {
if (!this.#dsRecord?.isReady) {
log.error("Record.initData() called before record ready.", this.#name)
return
}
// if (!data) return;
if (!overwrite && !isEmpty(this.#dsRecord.get())) return // don't overwrite existing data
if (!isJSONObject(data, "init-data")) return // don't try to write bad data
// todo: allow but warn non-owner writes
await this.#dsRecord.setWithAck(data)
}
setData(data: UserData): void {
if (!this.#dsRecord?.isReady) {
// prettier-ignore
log.error(
`Record.setData() called before record ready. ${
this.#name
}\n Ignored: ${JSON.stringify(data)}`
);
return
}
if (!isJSONObject(data, "set-data")) return // don't try to write bad data
// todo: allow but warn non-owner writes
this.#dsRecord.set(data)
}
watchShared(callback: SubscriptionCallback, triggerNow?: boolean): void
watchShared(
path: string,
callback: SubscriptionCallback,
triggerNow?: boolean
): void
watchShared(
path: string | SubscriptionCallback,
cb?: SubscriptionCallback | boolean,
triggerNow?: boolean
): void {
if (!this.#dsRecord?.isReady) {
log.warn(`watchShared() called on '${this.#name}' before ready.`)
return
}
// @ts-expect-error subscribe overload signatures DO match watchShared
this.#dsRecord.subscribe(path, cb, triggerNow)
}
get shared(): JSONObject {
return this.#watchedShared
}
get name(): string {
return this.#name
}
async delete() {
if (!this.#dsRecord?.isReady) {
log.error(`delete() called on ${this.#name} before ready.`)
return
}
return new Promise((resolve) => {
this.#dsRecord?.once("delete", resolve)
this.#dsRecord?.delete()
})
}
async _set(path: string, value: JSONValue) {
// value `as RecordData` because all JSONValues ARE supported
await this.#dsRecord?.setWithAck(path, value as RecordData)
}
_get(key?: string): JSONValue {
return this.#dsRecord?.get(key) as JSONValue
}
#onClientValidateData(
path: string,
newValue: UserData,
oldValue: UserData
): boolean {
return isJSONValue(newValue, `${this.#name}/${path}`)
}
#onClientChangeData(
path: string,
newValue: UserData,
oldValue: UserData
): void {
if (!this.#dsRecord?.isReady) {
// prettier-ignore
log.warn(
`Shared object written to before ready. ${
this.#name
}\n Ignored: ${path} = ${JSON.stringify(newValue)}`
);
return
}
//! experimental
// function get(obj: JSONValue, path: string) {
// return path.split(".").reduce((current, key) => {
// return current && current[key];
// }, obj);
// }
// function propertyIsEnumerable(obj: JSONValue, path: string) {
// const keys = path.split(".");
// const lastKey = keys.pop();
// const parent = keys.reduce((current, key) => {
// return current && current[key];
// }, obj);
// // return parent && parent.hasOwnProperty(lastKey);
// return (
// parent && Object.prototype.propertyIsEnumerable.call(parent, lastKey)
// );
// }
// if (!propertyIsEnumerable(this.#shared, path)) {
// return; // don't sync non-enumerable properties
// }
//! /experimental
// todo: warn and allow non-owner writes
// `as JSONValue` because newValue validated in onClientValidateData
this._set(path, newValue as JSONValue)
}
#onServerChangeData(data: JSONObject): void {
/* istanbul ignore next */ // the server should never be sending this
if (!isJSONObject(data, "server-data")) {
log.error(`Incoming server data not valid.`)
}
// don't replace #shared itself as #watchedShared has a reference to it
// instead patch it to match the incoming data
patchInPlace(this.#shared, data, "shared")
}
static recordForShared(watchedShared: JSONObject): Record | undefined {
const shared = onChange.target(watchedShared)
if (!sharedRecordLookup.has(shared)) {
log.error(`No record found for shared object.`)
return undefined
}
return sharedRecordLookup.get(shared)
}
}