import { observable, action } from "mobx"; import { UndoManager } from "../client/util/UndoManager"; import { serializable, primitive, map, alias, list } from "serializr"; import { autoObject, SerializationHelper, Deserializable } from "../client/util/SerializationHelper"; import { Utils } from "../Utils"; import { DocServer } from "../client/DocServer"; export const HandleUpdate = Symbol("HandleUpdate"); const Id = Symbol("Id"); export abstract class RefField { @serializable(alias("id", primitive())) private __id: string; readonly [Id]: string; constructor(id?: string) { this.__id = id || Utils.GenerateGuid(); this[Id] = this.__id; } protected [HandleUpdate]?(diff: any): void; } const Update = Symbol("Update"); const OnUpdate = Symbol("OnUpdate"); const Parent = Symbol("Parent"); export class ObjectField { protected [OnUpdate]?: (diff?: any) => void; private [Parent]?: Doc; } function url() { return { serializer: function (value: URL) { return value.href; }, deserializer: function (jsonValue: string, done: (err: any, val: any) => void) { done(undefined, new URL(jsonValue)); } }; } @Deserializable("url") export class URLField extends ObjectField { @serializable(url()) readonly url: URL; constructor(url: URL) { super(); this.url = url; } } @Deserializable("proxy") export class ProxyField extends ObjectField { constructor(); constructor(value: T); constructor(value?: T) { super(); if (value) { this.cache = value; this.fieldId = value[Id]; } } @serializable(primitive()) readonly fieldId: string = ""; // This getter/setter and nested object thing is // because mobx doesn't play well with observable proxies @observable.ref private _cache: { readonly field: T | undefined } = { field: undefined }; private get cache(): T | undefined { return this._cache.field; } private set cache(field: T | undefined) { this._cache = { field }; } private failed = false; private promise?: Promise; value(callback?: ((field: T | undefined) => void)): T | undefined | null { if (this.cache) { callback && callback(this.cache); return this.cache; } if (this.failed) { return undefined; } if (!this.promise) { // this.promise = Server.GetField(this.fieldId).then(action((field: any) => { // this.promise = undefined; // this.cache = field; // if (field === undefined) this.failed = true; // return field; // })); this.promise = new Promise(r => r()); } callback && this.promise.then(callback); return null; } } export type Field = number | string | boolean | ObjectField | RefField; export type Opt = T | undefined; export type FieldWaiting = null; export const FieldWaiting: FieldWaiting = null; const Self = Symbol("Self"); function setter(target: any, prop: string | symbol | number, value: any, receiver: any): boolean { if (SerializationHelper.IsSerializing()) { target[prop] = value; return true; } if (typeof prop === "symbol") { target[prop] = value; return true; } const curValue = target.__fields[prop]; if (curValue === value || (curValue instanceof ProxyField && value instanceof RefField && curValue.fieldId === value[Id])) { // TODO This kind of checks correctly in the case that curValue is a ProxyField and value is a RefField, but technically // curValue should get filled in with value if it isn't already filled in, in case we fetched the referenced field some other way return true; } if (value instanceof RefField) { value = new ProxyField(value); } if (value instanceof ObjectField) { if (value[Parent] && value[Parent] !== target) { throw new Error("Can't put the same object in multiple documents at the same time"); } value[Parent] = target; value[OnUpdate] = (diff?: any) => { if (!diff) diff = SerializationHelper.Serialize(value); target[Update]({ [prop]: diff }); }; } if (curValue instanceof ObjectField) { delete curValue[Parent]; delete curValue[OnUpdate]; } target.__fields[prop] = value; target[Update]({ ["fields." + prop]: value instanceof ObjectField ? SerializationHelper.Serialize(value) : (value === undefined ? null : value) }); UndoManager.AddEvent({ redo: () => receiver[prop] = value, undo: () => receiver[prop] = curValue }); return true; } function getter(target: any, prop: string | symbol | number, receiver: any): any { if (typeof prop === "symbol") { return target.__fields[prop] || target[prop]; } if (SerializationHelper.IsSerializing()) { return target[prop]; } return getField(target, prop, receiver); } function getField(target: any, prop: string | number, ignoreProto: boolean = false, callback?: (field: Field | undefined) => void): any { const field = target.__fields[prop]; if (field instanceof ProxyField) { return field.value(callback); } if (field === undefined && !ignoreProto) { const proto = getField(target, "prototype", true); if (proto instanceof Doc) { let field = proto[prop]; callback && callback(field === null ? undefined : field); return field; } } callback && callback(field); return field; } @Deserializable("list") class ListImpl extends ObjectField { constructor() { super(); const list = new Proxy(this, { set: function (a, b, c, d) { return setter(a, b, c, d); }, get: getter, deleteProperty: () => { throw new Error("Currently properties can't be deleted from documents, assign to undefined instead"); }, defineProperty: () => { throw new Error("Currently properties can't be defined on documents using Object.defineProperty"); }, }); return list; } [key: number]: T | null | undefined; @serializable(alias("fields", list(autoObject()))) @observable private __fields: (T | null | undefined)[] = []; private [Update] = (diff: any) => { console.log(diff); const update = this[OnUpdate]; update && update(diff); } private [Self] = this; } export type List = ListImpl & T[]; export const List: { new (): List } = ListImpl as any; @Deserializable("doc").withFields(["id"]) export class Doc extends RefField { constructor(id?: string, forceSave?: boolean) { super(id); const doc = new Proxy(this, { set: setter, get: getter, deleteProperty: () => { throw new Error("Currently properties can't be deleted from documents, assign to undefined instead"); }, defineProperty: () => { throw new Error("Currently properties can't be defined on documents using Object.defineProperty"); }, }); if (!id || forceSave) { DocServer.CreateField(SerializationHelper.Serialize(doc)); } return doc; } [key: string]: Field | null | undefined; @serializable(alias("fields", map(autoObject()))) @observable private __fields: { [key: string]: Field | null | undefined } = {}; private [Update] = (diff: any) => { DocServer.UpdateField(this[Id], diff); } private [Self] = this; } export namespace Doc { export function GetAsync(doc: Doc, key: string, ignoreProto: boolean = false): Promise { const self = doc[Self]; return new Promise(res => getField(self, key, ignoreProto, res)); } export const Prototype = Symbol("Prototype"); } export const GetAsync = Doc.GetAsync; export type ToType = T extends "string" ? string : T extends "number" ? number : T extends "boolean" ? boolean : T extends ListSpec ? List : T extends { new(...args: any[]): infer R } ? R : never; export type ToConstructor = T extends string ? "string" : T extends number ? "number" : T extends boolean ? "boolean" : { new(...args: any[]): T }; export type ToInterface = { [P in keyof T]: ToType; }; // type ListSpec = { List: FieldCtor> | ListSpec> }; export type ListSpec = { List: FieldCtor }; // type ListType = { 0: List>>, 1: ToType> }[HasTail extends true ? 0 : 1]; type Head = T extends [any, ...any[]] ? T[0] : never; type Tail = ((...t: T) => any) extends ((_: any, ...tail: infer TT) => any) ? TT : []; type HasTail = T extends ([] | [any]) ? false : true; interface Interface { [key: string]: ToConstructor | ListSpec; // [key: string]: ToConstructor | ListSpec; } type FieldCtor = ToConstructor | ListSpec; function Cast>(field: Field | undefined, ctor: T): ToType | undefined { if (field !== undefined) { if (typeof ctor === "string") { if (typeof field === ctor) { return field as ToType; } } else if (typeof ctor === "object") { if (field instanceof List) { return field as ToType; } } else if (field instanceof (ctor as any)) { return field as ToType; } } return undefined; } export type makeInterface = Partial> & Doc; export function makeInterface(schema: T): (doc: Doc) => makeInterface { return function (doc: any) { return new Proxy(doc, { get(target, prop) { const field = target[prop]; if (prop in schema) { return Cast(field, (schema as any)[prop]); } return field; } }); }; } export type makeStrictInterface = Partial>; export function makeStrictInterface(schema: T): (doc: Doc) => makeStrictInterface { const proto = {}; for (const key in schema) { const type = schema[key]; Object.defineProperty(proto, key, { get() { return Cast(this.__doc[key], type as any); }, set(value) { value = Cast(value, type as any); if (value !== undefined) { this.__doc[key] = value; return; } throw new TypeError("Expected type " + type); } }); } return function (doc: any) { const obj = Object.create(proto); obj.__doc = doc; return obj; }; } export function createSchema(schema: T): T { return schema; }