diff --git a/.eslintrc.js b/.eslintrc.js index dea65e61e..1556f5da6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,6 +3,7 @@ module.exports = { rules: { 'block-scoped-var': 'off', 'curly': ['error', 'multi-line', 'consistent'], + 'comma-dangle': ['error', 'only-multiline'], 'eqeqeq': ['error', 'allow-null'], 'guard-for-in': 'off', 'indent': ['error', 2, {SwitchCase: 1}], diff --git a/lib/Model/paths.js b/lib/Model/paths.js index 65f7d4f99..b81130951 100644 --- a/lib/Model/paths.js +++ b/lib/Model/paths.js @@ -2,17 +2,27 @@ var Model = require('./Model'); exports.mixin = {}; +/** + * Returns the absolute path segments for this model's scope, with an optional + * relative suffix subpath. + * + * Note: All returned path segments are strings. Some segments may be numbers + * in string form. + * + * @param {string | number | Model} [subpath] optional subpath + * @return {string[]} + */ Model.prototype._splitPath = function(subpath) { var path = this.path(subpath); return (path && path.split('.')) || []; }; /** - * Returns the path equivalent to the path of the current scoped model plus - * (optionally) a suffix subpath + * Returns the absolute path of the current scoped model plus (optionally) a + * suffix subpath. * - * @optional @param {String} subpath - * @return {String} absolute path + * @param {string | number | Model} [subpath] optional subpath + * @return {string} absolute path * @api public */ Model.prototype.path = function(subpath) { diff --git a/lib/index.d.ts b/lib/index.d.ts new file mode 100644 index 000000000..c710ceb98 --- /dev/null +++ b/lib/index.d.ts @@ -0,0 +1,508 @@ +import sharedb = require('sharedb'); + +// If `racer` were written as an ES module, then it would have a default export +// that's an instance of Racer, plus exports of module classes/types like Model. +// +// However, this is a CommonJS module, and to correctly model it in TypeScript, +// we'd have to do `const racer = new Racer(); export = racer;`. Unfortunately, +// as of TypeScript 3.4, we can't merge namespaces into object variables: +// https://github.com/Microsoft/TypeScript/issues/18163 +// +// That means we can't `export =` a Racer instance and also export +// classes/types. To work around this, we simulate the important Racer instance +// methods on the exported namespace. +export = racer; + +declare namespace racer { + // Simulate important Racer instance methods. + + /** Creates a new RacerBackend. Only available on the server. */ + export function createBackend(options?: ShareBackendOptions & {modelOptions?: ModelOptions}): RacerBackend; + + export function createModel(data?: ModelBundle): RootModel; + + // https://github.com/share/sharedb/blob/master/lib/backend.js + interface ShareBackendOptions { + db?: any; + extraDbs?: {[extraDbName: string]: any}; + pubsub?: any; + + disableDocAction?: boolean; + disableSpaceDelimitedActions?: boolean; + maxSubmitRetries?: number; + suppressPublish?: boolean; + } + + // + // backend.js + // + + class RacerBackend extends sharedb { + createModel(options?: ModelOptions): RootModel; + } + + // + // Model + // + + export class Model { + static INITS: ModelInitsFn[]; + + root: RootModel; + + // TODO: The Model class should be abstract, and this constructor + // should be on the subclass RootModel. + constructor(options?: ModelOptions); + + /** Returns a new Racer UUID. */ + id(): UUID; + + // + // Getter methods + // + + /** + * Returns a ChildModel scoped to a relative subpath under this model's path. + */ + at(subpath: Path): ChildModel; + at(): ChildModel; + + /** + * Returns a ChildModel scoped to an absolute path. + */ + scope(absolutePath: Path): ChildModel; + /** + * Returns a ChildModel scoped to the root path. + */ + scope(): ChildModel; + + /** + * Gets the value located at this model's path or a relative subpath. + * + * If no value exists at the path, this returns `undefined`. + * + * _Note:_ The value is returned by reference, and object values should not + * be directly modified - use the Model mutator methods instead. The + * TypeScript compiler will enforce no direct modifications, but there are + * no runtime guards, which means JavaScript source code could still + * improperly make direct modifications. + */ + get(subpath: Path): ReadonlyDeep | undefined; + get(): ReadonlyDeep | undefined; + + /** + * Gets a shallow copy of the value located at this model's path or a relative + * subpath. + * + * If no value exists at the path, this returns `undefined`. + */ + getCopy(subpath: Path): ShallowCopiedValue | undefined; + getCopy(): ShallowCopiedValue | undefined; + + /** + * Gets a deep copy of the value located at this model's path or a relative + * subpath. + * + * If no value exists at the path, this returns `undefined`. + */ + getDeepCopy(subpath: Path): S | undefined; + getDeepCopy(): T | undefined; + + // + // Mutator methods + // + + // This covers the JS interface, but it should eventually use stricter types + // based on . + add(subpath: Path, doc: JSONObject, cb?: Callback): string; + + /** + * Deletes the value at this model's path or a relative subpath. + * + * If a callback is provided, it's called when the write is finished. + * + * @returns the old value at the path + */ + del(subpath: Path, cb?: Callback): S | undefined; + del(cb?: Callback): T | undefined; + + /** + * Increments the value at this model's path or a relative subpath. + * + * If a callback is provided, it's called when the write is finished. + * + * @param byNumber amount to increment/decrement. Defaults to `1`. + * @returns the new number at the path + */ + increment(subpath: Path, byNumber?: number, cb?: Callback): number; + // Calling `increment()` with no arguments on a model pointing to a + // non-number results in `N` being `never`, but it still compiles. Is + // there a way to disallow that? + increment(byNumber?: N, cb?: Callback): number; + + /** + * Inserts one or more items at an index for the array at the path or + * relative subpath. + * + * If a callback is provided, it's called when the write is finished. + * + * @param index 0-based index at which to insert the new items + * @param values new item or items to insert + * @returns the new length of the array + */ + insert(subpath: Path, index: number, values: V | V[], cb?: Callback): number; + insert>(index: number, values: V | V[], cb?: Callback): number; + + /** + * Adds an item to the end of the array at this model's path or a relative + * subpath. If there's currently no value at the path, a new array is + * automatically set to the path first. + * + * If a callback is provided, it's called when the write is finished. + * + * @returns the new length of the array + */ + push(subpath: Path, item: V, cb?: Callback): number; + push>(item: V, cb?: Callback): number; + + /** + * Removes one or more items from the array at this model's path or a + * relative subpath. + * + * If a callback is provided, it's called when the write is finished. + * + * @param index 0-based index at which to start removing items + * @param howMany number of items to remove, defaults to `1` + * @returns array of the removed items + */ + remove(subpath: Path, index: number, howMany?: number, cb?: Callback): V[]; + // Calling `remove(n)` with one argument on a model pointing to a + // non-array results in `N` being `never`, but it still compiles. Is + // there a way to disallow that? + remove>(index: number, howMany?: number, cb?: Callback): V[]; + + /** + * Sets the value at this model's path or a relative subpath. + * + * If a callback is provided, it's called when the write is finished. + * + * @returns the value previously at the path + */ + set(subpath: Path, value: S, cb?: Callback): S | undefined; + set(value: T): T | undefined; + + /** + * Sets the value at this model's path or a relative subpath, if different + * from the current value based on a strict equality comparison (`===`). + * + * If a callback is provided, it's called when the write is finished. + * + * @returns the value previously at the path + */ + setDiff(subpath: Path, value: S, cb?: Callback): ReadonlyDeep | undefined; + setDiff(value: T): ReadonlyDeep | undefined; + + /** + * Sets the value at this model's path or a relative subpath, if different + * from the current value based on a recursive deep equal comparison. + * + * If a callback is provided, it's called when the write is finished. + * + * @returns the value previously at the path + */ + setDiffDeep(subpath: Path, value: S, cb?: Callback): ReadonlyDeep | undefined; + setDiffDeep(value: T): ReadonlyDeep | undefined; + + /** + * Sets the value at this model's path or a relative subpath, only if there + * isn't a value currently there. `null` and `undefined` count as no value. + * + * If a callback is provided, it's called when the write is finished. + * + * @returns the value currently at the path, if present, otherwise the `value` + * argument passed in + */ + setNull(subpath: Path, value: S, cb?: Callback): ReadonlyDeep | undefined; + setNull(value: T): ReadonlyDeep | undefined; + + // + // Fetch, subscribe + // + + fetch(items: Subscribable[], cb?: Callback): Model; + fetch(item: Subscribable, cb?: Callback): Model; + fetch(cb?: Callback): Model; + + subscribe(items: Subscribable[], cb?: Callback): Model; + subscribe(item: Subscribable, cb?: Callback): Model; + subscribe(cb?: Callback): Model; + + unfetch(items: Subscribable[], cb?: Callback): Model; + unfetch(item: Subscribable, cb?: Callback): Model; + unfetch(cb?: Callback): Model; + + unsubscribe(items: Subscribable[], cb?: Callback): Model; + unsubscribe(item: Subscribable, cb?: Callback): Model; + unsubscribe(cb?: Callback): Model; + + + // + // Query + // + + /** + * Creates a query on a particular collection. + * + * This method does not trigger any data loading. To do so, fetch or + * subscribe to the returned query. + * + * @param collectionName + * @param expression query expression - query filters and other parameters + * @param options + */ + query(collectionName: C, expression: JSONObject, options?: JSONObject): Query; + + // + // connection.js + // + + /** + * Calls the callback once all pending operations, fetches, and subscribes + * have settled. + */ + whenNothingPending(cb: Callback): void; + + // + // context.js + // + + /** + * Creates a new child model with a specific named data-loading context. The + * child model has the same scoped path as this model. + * + * Contexts are used to track counts of fetches and subscribes, so that all + * data relating to a context can be unloaded all at once, without having to + * manually track loaded data. + * + * Contexts are in a global namespace for each root model, so calling + * `model.context(contextId)` from two different places will return child + * models that both refer to the same context. + * + * @param contextId context id + * + * @see https://derbyjs.com/docs/derby-0.10/models/data-loading-contexts + */ + context(contextId: string): ChildModel; + + /** + * Unloads data for this model's context, or for a specific named context. + * + * @param contextId optional context to unload; defaults to this model's context + * + * @see https://derbyjs.com/docs/derby-0.10/models/data-loading-contexts + */ + unload(contextId?: string): void; + + /** + * Unloads data for all model contexts. + * + * @see https://derbyjs.com/docs/derby-0.10/models/data-loading-contexts + */ + unloadAll(): void; + + // + // Other methods that typically aren't used from outside Racer + // + + bundle(cb: (err?: Error, bundle?: ModelBundle) => void): void; + + getCollection(collectionName: string): Collection; + + destroy(subpath?: string): void; + + /** + * Returns the absolute path for this model's path, plus an optional subpath. + */ + path(subpath?: string): string; + + unbundle(data: ModelBundle): void; + } + + // The JavaScript code doesn't have a RootModel class. Instead, the root model + // is a Model that isn't a ChildModel. However, it really should have a + // RootModel class, since collections.js adds `collections` and `data` + // properties to root model instances. + export interface RootModel extends Model { + collections: CollectionMap; + data: ModelData; + } + + export class ChildModel extends Model { + // EventEmitter methods access these properties directly, so they must be + // inherited manually instead of via the root + _events: any; + _maxListeners: any; + + // Properties specific to a child instance + _context: any; + _at: string; + _pass: any; + _silent: any; + _eventContext: any; + _preventCompose: any; + } + + type ModelOptions = {debug?: ModelDebugOptions} | ModelInitsFnOptions; + + type ModelInitsFn = (model: RootModel, options: ModelInitsFnOptions) => void; + type ModelInitsFnOptions = { + bundleTimeout?: number; // bundle.js + fetchOnly?: boolean; // subscriptions.js + unloadDelay?: number; // subscriptions.js + }; + + type ModelDebugOptions = { + disableSubmit?: boolean; // RemoteDoc.js + remoteMutations?: boolean; // RemoteDoc.js + }; + + /** + * A path string, a `Model`, or a `Query`. + */ + type Subscribable = string | Model | Query; + + + + + // + // bundle.js + // + + interface ModelBundle { + queries: JSONObject; + contexts: JSONObject; + refs: JSONObject; + refLists: JSONObject; + fns: JSONObject; + filters: JSONObject; + collections: JSONObject; + } + + + + + // + // collections.js + // + + class CollectionMap { + [collectionName: string]: Collection; + } + /** Root model data */ + export class ModelData { + [collectionName: string]: CollectionData; + } + class DocMap { + [id: string]: Doc; + } + /** Dictionary of document id to document data */ + export class CollectionData { + [id: string]: T; + } + + class Collection { + model: RootModel; + name: string; + Doc: DocConstructor; + docs: DocMap; + data: CollectionData; + + constructor(model: RootModel, name: string, Doc: DocConstructor); + } + + type DocConstructor = { + new(): LocalDoc | RemoteDoc; + }; + + + + + // + // Doc.js, LocalDoc.js, RemoteDoc.js + // + + abstract class Doc {} + class LocalDoc extends Doc {} + class RemoteDoc extends Doc {} + + + + + // + // Query.js + // + + export class Query { + constructor(model: Model, collectionName: string, expression: JSONObject, options?: JSONObject); + + fetch(cb?: Callback): Query; + subscribe(cb?: Callback): Query; + unfetch(cb?: Callback): Query; + unsubscribe(cb?: Callback): Query; + + get(): T[]; + getIds(): string[]; + getExtra(): JSONObject; + } + + + + + // + // events.js + // + + interface ListenerEventMap { + } + + + + + // + // Simple and utility types + // + + export type UUID = string; + export type Path = string | number; + export type PathSegment = string | number; + + type JSONValue = string | number | boolean | null | JSONObject | JSONArray; + type JSONObject = { + [propName: string]: JSONValue; + // Union with `object` below is a workaround to allow interfaces to work, + // since interfaces don't work with the index signature above, but types do: + // https://github.com/Microsoft/TypeScript/issues/15300 + } | object; + interface JSONArray extends Array { } + + /** If `T` is an array, produces the type of the array items. */ + type ArrayItemType = T extends Array ? U : never; + + type ReadonlyDeep = + // T extends Array ? ReadonlyArrayDeep : + { readonly [K in keyof T]: ReadonlyDeep }; + + // ReadonlyArrayDeep is not needed as of TypeScript 3.4. + // + // This was a workaround for recursive types: + // https://github.com/Microsoft/TypeScript/issues/3496#issuecomment-128553540 + interface ReadonlyArrayDeep extends ReadonlyArray> {} + + // Model#getCopy(...) returns a shallow copy. Direct edits on the returned + // object's properties are fine, but direct edits deeper down are not OK. + type ShallowCopiedValue = + T extends Array ? Array> : + { [K in keyof T]: ReadonlyDeep }; + + type Callback = (err?: Error) => void; +} diff --git a/lib/util.js b/lib/util.js index bcb2c46bf..9667ba817 100644 --- a/lib/util.js +++ b/lib/util.js @@ -1,3 +1,6 @@ +// @ts-check + +/** @type {(a: any, b: any) => boolean} */ var deepIs = require('deep-is'); var isServer = process.title !== 'browser'; @@ -21,6 +24,13 @@ exports.serverRequire = serverRequire; exports.serverUse = serverUse; exports.use = use; +/** + * @typedef {string | number} PathSegment + */ + + /** + * @param {Function} cb + */ function asyncGroup(cb) { var group = new AsyncGroup(cb); return function asyncGroupAdd() { @@ -30,7 +40,7 @@ function asyncGroup(cb) { /** * @constructor - * @param {Function} cb(err) + * @param {Function} cb */ function AsyncGroup(cb) { this.cb = cb; @@ -54,6 +64,10 @@ AsyncGroup.prototype.add = function() { }; }; +/** + * Converts any segments that are strings of digits into numbers, in-place. + * @param {PathSegment[]} segments + */ function castSegments(segments) { // Cast number path segments from strings to numbers for (var i = segments.length; i--;) { @@ -65,6 +79,11 @@ function castSegments(segments) { return segments; } +/** + * Returns whether `segments` has `pathSegments` as a prefix. + * @param {PathSegment[]} segments + * @param {PathSegment[]} testSegments + */ function contains(segments, testSegments) { for (var i = 0; i < segments.length; i++) { if (segments[i] !== testSegments[i]) return false; @@ -72,6 +91,11 @@ function contains(segments, testSegments) { return true; } +/** + * @template T + * @param {T} value + * @return T + */ function copy(value) { if (value instanceof Date) return new Date(value); if (typeof value === 'object') { @@ -82,6 +106,11 @@ function copy(value) { return value; } +/** + * @template {Object} T + * @param {T} object + * @return T + */ function copyObject(object) { var out = new object.constructor(); for (var key in object) { @@ -92,6 +121,11 @@ function copyObject(object) { return out; } +/** + * @template {any} T + * @param {T} value + * @return T + */ function deepCopy(value) { if (value instanceof Date) return new Date(value); if (typeof value === 'object') { @@ -127,6 +161,10 @@ function isArrayIndex(segment) { return (/^[0-9]+$/).test(segment); } +/** + * @param {PathSegment[] | undefined} segments + * @param {*} value + */ function lookup(segments, value) { if (!segments) return value; @@ -137,21 +175,37 @@ function lookup(segments, value) { return value; } -function mayImpactAny(segmentsList, testSegments) { +/** + * Returns whether an event's path could impact any of a listener's paths. + * @param {PathSegment[][]} segmentsList listener's paths, each path as segments + * @param {PathSegment[]} eventSegments path segments for event + */ +function mayImpactAny(segmentsList, eventSegments) { for (var i = 0, len = segmentsList.length; i < len; i++) { - if (mayImpact(segmentsList[i], testSegments)) return true; + if (mayImpact(segmentsList[i], eventSegments)) return true; } return false; } -function mayImpact(segments, testSegments) { - var len = Math.min(segments.length, testSegments.length); +/** + * Returns whether an event could impact a listener, based on their model paths. + * @param {PathSegment[]} segments path segments for listener + * @param {PathSegment[]} eventSegments path segments for event + */ +function mayImpact(segments, eventSegments) { + var len = Math.min(segments.length, eventSegments.length); for (var i = 0; i < len; i++) { - if (segments[i] !== testSegments[i]) return false; + if (segments[i] !== eventSegments[i]) return false; } return true; } +/** + * @template T + * @param {T} to + * @param {*} from + * @return {T} + */ function mergeInto(to, from) { for (var key in from) { to[key] = from[key]; diff --git a/package.json b/package.json index be35f0bb4..0d0eb36df 100644 --- a/package.json +++ b/package.json @@ -8,24 +8,34 @@ }, "version": "0.9.4", "main": "./lib/index.js", + "types": "./lib/index.d.ts", "scripts": { - "lint": "eslint --ignore-path .gitignore .", - "test": "node_modules/.bin/mocha && npm run lint", - "test-cover": "node_modules/istanbul/lib/cli.js cover node_modules/mocha/bin/_mocha && npm run lint" + "lint": "eslint --ignore-path .gitignore --ignore-pattern prettier.config.js . && gts check", + "test": "mocha && npm run lint", + "test-cover": "node_modules/istanbul/lib/cli.js cover node_modules/mocha/bin/_mocha && npm run lint", + "check": "npm run lint", + "fix": "gts fix" }, "dependencies": { + "@types/sharedb": "^1.0.3", "arraydiff": "^0.1.1", "deep-is": "^0.1.3", "sharedb": "^1.0.0-beta", "uuid": "^2.0.1" }, "devDependencies": { + "@types/expect.js": "^0.3.29", + "@types/mocha": "^2.2.48", + "@types/node": "^12.0.8", "coveralls": "^2.11.8", "eslint": "^2.9.0", "eslint-config-xo": "^0.14.1", "expect.js": "^0.3.1", + "gts": "^1.0.0", "istanbul": "^0.4.2", - "mocha": "^2.3.3" + "mocha": "^2.3.3", + "ts-node": "^8.3.0", + "typescript": "~3.4.0" }, "bugs": { "url": "https://github.com/derbyjs/racer/issues" diff --git a/prettier.config.js b/prettier.config.js new file mode 100644 index 000000000..a425d3f76 --- /dev/null +++ b/prettier.config.js @@ -0,0 +1,4 @@ +module.exports = { + singleQuote: true, + trailingComma: 'es5', +}; diff --git a/test/mocha.opts b/test/mocha.opts index f3d32c552..149489041 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -1,3 +1,4 @@ +--compilers ts:ts-node/register --reporter spec --timeout 1200 --check-leaks diff --git a/test/types-test.ts b/test/types-test.ts new file mode 100644 index 000000000..b0ee9e529 --- /dev/null +++ b/test/types-test.ts @@ -0,0 +1,495 @@ +import expect = require('expect.js'); +import racer = require('../lib'); +import { Model, RootModel, CollectionData } from '../lib'; + +interface Book { + id: string; + author?: Author; + pages: Page[]; + publishedAt?: number; +} + +interface Author { + id: string; + name: string; +} + +// Make sure both interfaces and types work as Model generic types. +// tslint:disable-next-line: interface-over-type-literal +type Page = { + text: string; +}; + +// Use TypeScript module augmentation on the root model's ModelData to add +// information on each collection's document types. +// +// In actual usages of Racer, this would be `declare module 'racer'` instead of +// `declare module '../lib'`: +// +// import racer = require('racer'); +// declare module 'racer' { ... } +// +declare module '../lib' { + interface ModelData { + books: racer.CollectionData; + } +} + +describe('TypeScript Model', () => { + let backend: racer.RacerBackend; + let rootModel: RootModel; + beforeEach(() => { + backend = racer.createBackend({ + disableDocAction: true, + disableSpaceDelimitedActions: true, + }); + rootModel = backend.createModel(); + }); + + // + // Getters + // + + describe('get', () => { + let book1: Book; + let book1Id: string; + beforeEach(() => { + book1 = { + id: 'my-book', + author: { id: 'alex-uuid', name: 'Alex' }, + pages: [], + publishedAt: 1234, + }; + book1Id = rootModel.add('books', book1); + }); + + describe('with root model', () => { + it('can return a whole document', () => { + const book = rootModel.get(`books.${book1Id}`); + expect(book).to.eql(book1); + }); + + it('can return a document field', () => { + const author = rootModel.get(`books.${book1Id}.author`); + expect(author).to.eql(book1.author); + }); + + it('can return a document subfield', () => { + const authorName = rootModel.get( + `books.${book1Id}.author.name` + ); + expect(authorName).to.eql('Alex'); + }); + + it('returns undefined for a non-existent value', () => { + const nonExistentBook = rootModel.get('books.non-existent'); + expect(nonExistentBook).to.be(undefined); + }); + + it('returns values by reference', () => { + // Model#get returns values by reference, so for an object value, a + // later change to a property via model methods should be reflected in the + // previously returned object. + const book = rootModel.get(`books.${book1Id}`); + expect(book).to.have.property('publishedAt', 1234); + rootModel.set(`books.${book1Id}.publishedAt`, 5678); + expect(book).to.have.property('publishedAt', 5678); + }); + }); + + describe('with child model', () => { + let bookModel: Model; + beforeEach(() => { + bookModel = rootModel.at(`books.${book1Id}`); + }); + + it('can return a whole document', () => { + const book = bookModel.get(); + expect(book).to.eql(book1); + }); + + it('can return a document field', () => { + const author = bookModel.get('author'); + expect(author).to.eql(book1.author); + }); + + it('can return a document subfield', () => { + const authorName = bookModel.get('author.name'); + expect(authorName).to.eql('Alex'); + }); + + it('returns undefined for a non-existent value', () => { + const nonExistent = bookModel.get('pages.12'); + expect(nonExistent).to.be(undefined); + }); + }); + }); + + // + // Mutators + // + + describe('increment', () => { + let book1: Book; + let book1Id: string; + let book1Model: Model; + beforeEach(() => { + book1 = { + id: 'my-book', + pages: [], + publishedAt: 100, + }; + book1Id = rootModel.add('books', book1); + book1Model = rootModel.at(`books.${book1Id}`); + }); + + it('with no arguments - increments model value by 1', () => { + const publishedAtModel = book1Model.at('publishedAt'); + const returnValue = publishedAtModel.increment(); + expect(returnValue).to.equal(101); + expect(book1Model.get()).to.have.property('publishedAt', 101); + }); + + it('with positive number argument - increments model value by that number', () => { + const publishedAtModel = book1Model.at('publishedAt'); + const returnValue = publishedAtModel.increment(25); + expect(returnValue).to.equal(125); + expect(book1Model.get()).to.have.property('publishedAt', 125); + }); + + it('with negative number argument - decrements model value by that number', () => { + const publishedAtModel = book1Model.at('publishedAt'); + const returnValue = publishedAtModel.increment(-25); + expect(returnValue).to.equal(75); + expect(book1Model.get()).to.have.property('publishedAt', 75); + }); + + it('with subpath argument - increments model value by 1', () => { + const returnValue = rootModel.increment(`books.${book1Id}.publishedAt`); + expect(returnValue).to.equal(101); + expect(book1Model.get()).to.have.property('publishedAt', 101); + }); + + it('with subpath and number arguments - increments model value by that number', () => { + const returnValue = rootModel.increment( + `books.${book1Id}.publishedAt`, + 25 + ); + expect(returnValue).to.equal(125); + expect(book1Model.get()).to.have.property('publishedAt', 125); + }); + }); + + describe('push', () => { + let book: Book; + let bookId: string; + let bookModel: Model; + beforeEach(() => { + book = { + id: 'my-book', + pages: [], + }; + bookId = rootModel.add('books', book); + bookModel = rootModel.at(`books.${bookId}`); + }); + + it('onto pre-existing array', () => { + const pagesModel = bookModel.at('pages'); + const returnValue1 = pagesModel.push({ text: 'Page 1' }); + expect(returnValue1).to.equal(1); + expect(bookModel.get()) + .to.have.property('pages') + .eql([{ text: 'Page 1' }]); + const returnValue2 = pagesModel.push({ text: 'Page 2' }); + expect(returnValue2).to.equal(2); + expect(bookModel.get()) + .to.have.property('pages') + .eql([{ text: 'Page 1' }, { text: 'Page 2' }]); + }); + + it('onto path with no value will first set a new array', () => { + bookModel.del('pages'); + expect(bookModel.get()).to.not.have.property('pages'); + + const returnValue = bookModel.push('pages', { text: 'Page 1' }); + expect(returnValue).to.equal(1); + expect(bookModel.get()) + .to.have.property('pages') + .eql([{ text: 'Page 1' }]); + }); + }); + + describe('insert', () => { + let book: Book; + let bookId: string; + let bookModel: Model; + let pagesModel: Model; + beforeEach(() => { + book = { + id: 'my-book', + pages: [], + }; + bookId = rootModel.add('books', book); + bookModel = rootModel.at(`books.${bookId}`); + pagesModel = bookModel.at('pages'); + }); + + it('with single new item', () => { + const returnValue1 = pagesModel.insert(0, { text: 'Page 3' }); + expect(returnValue1).to.equal(1); + expect(bookModel.get()) + .to.have.property('pages') + .eql([{ text: 'Page 3' }]); + const returnValue2 = pagesModel.insert(0, { text: 'Page 1' }); + expect(returnValue2).to.equal(2); + expect(bookModel.get()) + .to.have.property('pages') + .eql([{ text: 'Page 1' }, { text: 'Page 3' }]); + const returnValue3 = pagesModel.insert(1, { text: 'Page 2' }); + expect(returnValue3).to.equal(3); + expect(bookModel.get()) + .to.have.property('pages') + .eql([{ text: 'Page 1' }, { text: 'Page 2' }, { text: 'Page 3' }]); + }); + + it('with array of new items', () => { + const returnValue1 = pagesModel.insert(0, [{ text: 'Page 3' }]); + expect(returnValue1).to.equal(1); + expect(bookModel.get()) + .to.have.property('pages') + .eql([{ text: 'Page 3' }]); + const returnValue2 = pagesModel.insert(0, [ + { text: 'Page 1' }, + { text: 'Page 2' }, + ]); + expect(returnValue2).to.equal(3); + expect(bookModel.get()) + .to.have.property('pages') + .eql([{ text: 'Page 1' }, { text: 'Page 2' }, { text: 'Page 3' }]); + const returnValue3 = pagesModel.insert(2, [ + { text: 'Page 2.1' }, + { text: 'Page 2.2' }, + ]); + expect(returnValue3).to.equal(5); + expect(bookModel.get()) + .to.have.property('pages') + .eql([ + { text: 'Page 1' }, + { text: 'Page 2' }, + { text: 'Page 2.1' }, + { text: 'Page 2.2' }, + { text: 'Page 3' }, + ]); + }); + }); + + describe('remove', () => { + let book: Book; + let bookId: string; + let bookModel: Model; + let pagesModel: Model; + beforeEach(() => { + book = { + id: 'my-book', + pages: [], + }; + bookId = rootModel.add('books', book); + bookModel = rootModel.at(`books.${bookId}`); + pagesModel = bookModel.at('pages'); + pagesModel.set([ + { text: 'Page 1' }, + { text: 'Page 2' }, + { text: 'Page 3' }, + ]); + }); + + it('default of one item', () => { + const removedItems = pagesModel.remove(1); + expect(removedItems).to.eql([{ text: 'Page 2' }]); + expect(bookModel.get()) + .to.have.property('pages') + .eql([{ text: 'Page 1' }, { text: 'Page 3' }]); + }); + + it('three items', () => { + const removedItems = pagesModel.remove(0, 3); + expect(removedItems).to.eql([ + { text: 'Page 1' }, + { text: 'Page 2' }, + { text: 'Page 3' }, + ]); + expect(bookModel.get()) + .to.have.property('pages') + .eql([]); + }); + }); + + // + // Fetch, subscribe + // + + describe('document fetch', () => { + let book: Book; + let bookId: string; + let clientModel: RootModel; + beforeEach(done => { + book = { + id: 'my-book', + author: { id: 'alex-uuid', name: 'Alex' }, + pages: [], + publishedAt: 1234, + }; + bookId = rootModel.add('books', book); + clientModel = backend.createModel(); + rootModel.whenNothingPending(done); + }); + + function testDocFetch(toFetch: racer.Subscribable, done: MochaDone) { + clientModel.fetch(toFetch, err => { + if (err) { + return done(err); + } + expect(clientModel.get('books')).eql({ + 'my-book': book, + }); + // A "remote" change shouldn't show up in the model. + rootModel.set(`books.${bookId}.publishedAt`, 5678, () => { + expect(clientModel.get(`books.${bookId}.publishedAt`)).eql(1234); + done(); + }); + }); + } + + it('with collection+id string', done => { + testDocFetch(`books.${bookId}`, done); + }); + + it('with scoped model', done => { + const bookModel = clientModel.at(`books.${bookId}`); + testDocFetch(bookModel, done); + }); + }); + + describe('document subscribe', () => { + let book: Book; + let bookId: string; + let clientModel: RootModel; + beforeEach(done => { + book = { + id: 'my-book', + author: { id: 'alex-uuid', name: 'Alex' }, + pages: [], + publishedAt: 1234, + }; + bookId = rootModel.add('books', book); + clientModel = backend.createModel(); + rootModel.whenNothingPending(done); + }); + + function testDocSubscribe(toFetch: racer.Subscribable, done: MochaDone) { + clientModel.subscribe(toFetch, err => { + if (err) { + return done(err); + } + expect(clientModel.get('books')).eql({ + 'my-book': book, + }); + // A "remote" change should show up in the model. + rootModel.set(`books.${bookId}.publishedAt`, 5678, () => { + // Change is done, but it will take one more tick for the change to + // propagate to the other subscribed model. + process.nextTick(() => { + expect(clientModel.get(`books.${bookId}.publishedAt`)).eql(5678); + done(); + }); + }); + }); + } + + it('with collection+id string', done => { + testDocSubscribe(`books.${bookId}`, done); + }); + + it('with scoped model', done => { + const bookModel = clientModel.at(`books.${bookId}`); + testDocSubscribe(bookModel, done); + }); + }); + + describe('query subscribe', () => { + let book: Book; + let bookId: string; + let clientModel: RootModel; + beforeEach(done => { + book = { + id: 'my-book', + author: { id: 'alex-uuid', name: 'Alex' }, + pages: [], + publishedAt: 1234, + }; + bookId = rootModel.add('books', book); + clientModel = backend.createModel(); + rootModel.whenNothingPending(done); + }); + + function verifyRemoteChange(query: racer.Query, done: MochaDone) { + const books: Book[] = query.get(); + expect(books).eql([book]); + // A "remote" change should show up in the model. + rootModel.set(`books.${bookId}.publishedAt`, 5678, () => { + // Change is done, but it will take one more tick for the change to + // propagate to the other subscribed model. + process.nextTick(() => { + expect(clientModel.get(`books.${bookId}.publishedAt`)).eql(5678); + done(); + }); + }); + } + + it('with Query#subscribe(cb)', done => { + const query = clientModel.query('books', {}); + query.subscribe(err => { + if (err) { + return done(err); + } + verifyRemoteChange(query, done); + }); + }); + + it('with Model#subscribe(query, cb)', done => { + const query = clientModel.query('books', {}); + clientModel.subscribe(query, err => { + if (err) { + return done(err); + } + verifyRemoteChange(query, done); + }); + }); + + it('picks up new docs', done => { + const query = clientModel.query('books', {}); + query.subscribe(err => { + if (err) { + return done(err); + } + let books: Book[] = query.get(); + expect(books).eql([book]); + // A "remote" addition should show up in the model. + const newBook = { + id: 'new-book', + pages: [], + }; + rootModel.add('books', newBook); + rootModel.whenNothingPending(() => { + // Change is done, but it will take one more tick for the change to + // propagate to the other subscribed model. + process.nextTick(() => { + books = query.get(); + expect(books).eql([book, newBook]); + done(); + }); + }); + }); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..9145fc5a6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "./node_modules/gts/tsconfig-google.json", + "compilerOptions": { + "noEmit": true + }, + "include": [ + "lib/**/*.js", + "lib/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 000000000..27872a139 --- /dev/null +++ b/tslint.json @@ -0,0 +1,8 @@ +{ + "extends": "gts/tslint.json", + "linterOptions": { + "exclude": [ + "**/*.json" + ] + } +}