diff --git a/packages/@orbit/record-cache/src/async-record-cache.ts b/packages/@orbit/record-cache/src/async-record-cache.ts index 5a2cc6d59..4aeac008d 100644 --- a/packages/@orbit/record-cache/src/async-record-cache.ts +++ b/packages/@orbit/record-cache/src/async-record-cache.ts @@ -38,6 +38,7 @@ import { } from './record-accessor'; import { PatchResult } from './patch-result'; import { QueryResult, QueryResultData } from './query-result'; +import { AsyncLiveQuery } from './live-query/async-live-query'; const { assert } = Orbit; @@ -246,6 +247,24 @@ export abstract class AsyncRecordCache implements Evented, AsyncRecordAccessor { return result; } + liveQuery( + queryOrExpressions: QueryOrExpressions, + options?: object, + id?: string + ): AsyncLiveQuery { + const query = buildQuery( + queryOrExpressions, + options, + id, + this.queryBuilder + ); + + return new AsyncLiveQuery({ + cache: this, + query + }); + } + ///////////////////////////////////////////////////////////////////////////// // Protected methods ///////////////////////////////////////////////////////////////////////////// diff --git a/packages/@orbit/record-cache/src/index.ts b/packages/@orbit/record-cache/src/index.ts index 7216a374c..f1c4988c2 100644 --- a/packages/@orbit/record-cache/src/index.ts +++ b/packages/@orbit/record-cache/src/index.ts @@ -4,9 +4,11 @@ export * from './record-accessor'; export * from './async-record-cache'; export * from './async-operation-processor'; +export * from './live-query/async-live-query'; export * from './sync-record-cache'; export * from './sync-operation-processor'; +export * from './live-query/sync-live-query'; // Operators export * from './operators/async-inverse-patch-operators'; diff --git a/packages/@orbit/record-cache/src/live-query/async-live-query.ts b/packages/@orbit/record-cache/src/live-query/async-live-query.ts new file mode 100644 index 000000000..a43c57732 --- /dev/null +++ b/packages/@orbit/record-cache/src/live-query/async-live-query.ts @@ -0,0 +1,57 @@ +import { Schema, Query } from '@orbit/data'; +import { QueryResult } from '../query-result'; +import { AsyncRecordCache } from '../async-record-cache'; +import { LiveQuery, LiveQuerySettings } from './live-query'; + +export interface AsyncLiveQueryUpdateSettings { + cache: AsyncRecordCache; + query: Query; +} + +export class AsyncLiveQueryUpdate { + private _cache: AsyncRecordCache; + private _query: Query; + + constructor(settings: AsyncLiveQueryUpdateSettings) { + this._cache = settings.cache; + this._query = settings.query; + } + + query(): Promise { + return this._cache.query(this._query); + } +} + +export interface AsyncLiveQuerySettings extends LiveQuerySettings { + cache: AsyncRecordCache; +} + +export class AsyncLiveQuery extends LiveQuery { + protected cache: AsyncRecordCache; + + protected get schema(): Schema { + return this.cache.schema; + } + + private get _update() { + return new AsyncLiveQueryUpdate({ + cache: this.cache, + query: this._query + }); + } + + constructor(settings: AsyncLiveQuerySettings) { + super(settings); + this.cache = settings.cache; + } + + async query(): Promise { + return this._update.query(); + } + + subscribe(cb: (update: AsyncLiveQueryUpdate) => void): () => void { + return this._subscribe(() => { + cb(this._update); + }); + } +} diff --git a/packages/@orbit/record-cache/src/live-query/live-query.ts b/packages/@orbit/record-cache/src/live-query/live-query.ts new file mode 100644 index 000000000..cee0efaf0 --- /dev/null +++ b/packages/@orbit/record-cache/src/live-query/live-query.ts @@ -0,0 +1,182 @@ +import Orbit, { Evented } from '@orbit/core'; +import { + QueryExpression, + FindRecord, + FindRecords, + FindRelatedRecord, + FindRelatedRecords, + equalRecordIdentities, + Query, + Schema, + RecordOperation +} from '@orbit/data'; + +import { RecordChange, recordOperationChange } from './record-change'; + +const { assert } = Orbit; + +export interface LiveQuerySettings { + query: Query; +} + +export abstract class LiveQuery { + protected cache: Evented; + protected schema: Schema; + + protected _query: Query; + protected _subscribe(onNext: () => void): () => void { + const execute = onceTick(onNext); + + const unsubscribePatch = this.cache.on( + 'patch', + (operation: RecordOperation) => { + if (this.operationRelevantForQuery(operation)) { + execute(); + } + } + ); + + const unsubscribeReset = this.cache.on('reset', () => { + execute(); + }); + + function unsubscribe() { + cancelTick(execute); + unsubscribePatch(); + unsubscribeReset(); + } + + return unsubscribe; + } + + constructor(settings: LiveQuerySettings) { + assert( + 'Only single expression queries are supported on LiveQuery', + settings.query.expressions.length === 1 + ); + this._query = settings.query; + } + + operationRelevantForQuery(operation: RecordOperation): boolean { + const change = recordOperationChange(operation); + const expression = this._query.expressions[0]; + return this.queryExpressionRelevantForChange(expression, change); + } + + protected queryExpressionRelevantForChange( + expression: QueryExpression, + change: RecordChange + ): boolean { + switch (expression.op) { + case 'findRecord': + return this.findRecordQueryExpressionRelevantForChange( + expression as FindRecord, + change + ); + case 'findRecords': + return this.findRecordsQueryExpressionRelevantForChange( + expression as FindRecords, + change + ); + case 'findRelatedRecord': + return this.findRelatedRecordQueryExpressionRelevantForChange( + expression as FindRelatedRecord, + change + ); + case 'findRelatedRecords': + return this.findRelatedRecordsQueryExpressionRelevantForChange( + expression as FindRelatedRecords, + change + ); + default: + return true; + } + } + + protected findRecordQueryExpressionRelevantForChange( + expression: FindRecord, + change: RecordChange + ): boolean { + return equalRecordIdentities(expression.record, change); + } + + protected findRecordsQueryExpressionRelevantForChange( + expression: FindRecords, + change: RecordChange + ): boolean { + if (expression.type) { + return expression.type === change.type; + } else if (expression.records) { + for (let record of expression.records) { + if (record.type === change.type) { + return true; + } + } + return false; + } + return true; + } + + protected findRelatedRecordQueryExpressionRelevantForChange( + expression: FindRelatedRecord, + change: RecordChange + ): boolean { + return ( + equalRecordIdentities(expression.record, change) && + (change.relationships.includes(expression.relationship) || change.remove) + ); + } + + protected findRelatedRecordsQueryExpressionRelevantForChange( + expression: FindRelatedRecords, + change: RecordChange + ): boolean { + const { type } = this.schema.getRelationship( + expression.record.type, + expression.relationship + ); + + if (Array.isArray(type) && type.find(type => type === change.type)) { + return true; + } else if (type === change.type) { + return true; + } + + return ( + equalRecordIdentities(expression.record, change) && + (change.relationships.includes(expression.relationship) || change.remove) + ); + } +} + +const isNode = + typeof process === 'object' && typeof process.nextTick === 'function'; +let resolvedPromise: Promise; +const nextTick = isNode + ? function(fn: () => void) { + if (!resolvedPromise) { + resolvedPromise = Promise.resolve(); + } + resolvedPromise.then(() => { + process.nextTick(fn); + }); + } + : window.setImmediate || setTimeout; + +function onceTick(fn: () => void) { + return function tick() { + if (!ticks.has(tick)) { + ticks.add(tick); + nextTick(() => { + fn(); + cancelTick(tick); + }); + } + }; +} + +function cancelTick(tick: () => void) { + ticks.delete(tick); +} + +const ticks = new WeakSet(); diff --git a/packages/@orbit/record-cache/src/live-query/record-change.ts b/packages/@orbit/record-cache/src/live-query/record-change.ts new file mode 100644 index 000000000..349f4bb65 --- /dev/null +++ b/packages/@orbit/record-cache/src/live-query/record-change.ts @@ -0,0 +1,68 @@ +import { + Record, + cloneRecordIdentity, + RecordIdentity, + RecordOperation +} from '@orbit/data'; + +export interface RecordChange extends RecordIdentity { + keys: string[]; + attributes: string[]; + relationships: string[]; + meta: string[]; + links: string[]; + remove: boolean; +} + +export function recordOperationChange( + operation: RecordOperation +): RecordChange { + const record = operation.record as Record; + const change: RecordChange = { + ...cloneRecordIdentity(record), + remove: false, + keys: [], + attributes: [], + relationships: [], + meta: [], + links: [] + }; + + switch (operation.op) { + case 'addRecord': + case 'updateRecord': + if (record.keys) { + change.keys = Object.keys(record.keys); + } + if (record.attributes) { + change.attributes = Object.keys(record.attributes); + } + if (record.relationships) { + change.relationships = Object.keys(record.relationships); + } + if (record.meta) { + change.meta = Object.keys(record.meta); + } + if (record.links) { + change.links = Object.keys(record.links); + } + break; + case 'replaceAttribute': + change.attributes = [operation.attribute]; + break; + case 'replaceKey': + change.keys = [operation.key]; + break; + case 'replaceRelatedRecord': + case 'replaceRelatedRecords': + case 'addToRelatedRecords': + case 'removeFromRelatedRecords': + change.relationships = [operation.relationship]; + break; + case 'removeRecord': + change.remove = true; + break; + } + + return change; +} diff --git a/packages/@orbit/record-cache/src/live-query/sync-live-query.ts b/packages/@orbit/record-cache/src/live-query/sync-live-query.ts new file mode 100644 index 000000000..5e125ec2a --- /dev/null +++ b/packages/@orbit/record-cache/src/live-query/sync-live-query.ts @@ -0,0 +1,57 @@ +import { Schema, Query } from '@orbit/data'; +import { QueryResult } from '../query-result'; +import { SyncRecordCache } from '../sync-record-cache'; +import { LiveQuery, LiveQuerySettings } from './live-query'; + +export interface SyncLiveQueryUpdateSettings { + cache: SyncRecordCache; + query: Query; +} + +export class SyncLiveQueryUpdate { + private _cache: SyncRecordCache; + private _query: Query; + + constructor(settings: SyncLiveQueryUpdateSettings) { + this._cache = settings.cache; + this._query = settings.query; + } + + query(): QueryResult { + return this._cache.query(this._query); + } +} + +export interface SyncLiveQuerySettings extends LiveQuerySettings { + cache: SyncRecordCache; +} + +export class SyncLiveQuery extends LiveQuery { + protected cache: SyncRecordCache; + + protected get schema(): Schema { + return this.cache.schema; + } + + private get _update() { + return new SyncLiveQueryUpdate({ + cache: this.cache, + query: this._query + }); + } + + constructor(settings: SyncLiveQuerySettings) { + super(settings); + this.cache = settings.cache; + } + + query(): QueryResult { + return this._update.query(); + } + + subscribe(cb: (update: SyncLiveQueryUpdate) => void): () => void { + return this._subscribe(() => { + cb(this._update); + }); + } +} diff --git a/packages/@orbit/record-cache/src/sync-record-cache.ts b/packages/@orbit/record-cache/src/sync-record-cache.ts index 65e2738a4..9503778d6 100644 --- a/packages/@orbit/record-cache/src/sync-record-cache.ts +++ b/packages/@orbit/record-cache/src/sync-record-cache.ts @@ -38,6 +38,7 @@ import { } from './record-accessor'; import { PatchResult } from './patch-result'; import { QueryResult, QueryResultData } from './query-result'; +import { SyncLiveQuery } from './live-query/sync-live-query'; const { assert } = Orbit; @@ -242,6 +243,24 @@ export abstract class SyncRecordCache implements Evented, SyncRecordAccessor { return result; } + liveQuery( + queryOrExpressions: QueryOrExpressions, + options?: object, + id?: string + ): SyncLiveQuery { + const query = buildQuery( + queryOrExpressions, + options, + id, + this.queryBuilder + ); + + return new SyncLiveQuery({ + cache: this, + query + }); + } + ///////////////////////////////////////////////////////////////////////////// // Protected methods ///////////////////////////////////////////////////////////////////////////// diff --git a/packages/@orbit/record-cache/test/async-record-cache-test.ts b/packages/@orbit/record-cache/test/async-record-cache-test.ts index d3023dc8f..ab53153bd 100644 --- a/packages/@orbit/record-cache/test/async-record-cache-test.ts +++ b/packages/@orbit/record-cache/test/async-record-cache-test.ts @@ -3933,4 +3933,276 @@ module('AsyncRecordCache', function(hooks) { [jupiter, mars] ); }); + + test('#liveQuery', async function(assert) { + let cache = new Cache({ schema, keyMap }); + + const jupiter: Record = { + id: 'jupiter', + type: 'planet', + attributes: { name: 'Jupiter' } + }; + + const jupiter2 = { + ...jupiter, + attributes: { name: 'Jupiter 2' } + }; + + const callisto: Record = { + id: 'callisto', + type: 'moon', + attributes: { name: 'Callisto' }, + relationships: { planet: { data: { type: 'planet', id: 'jupiter' } } } + }; + + const jupiterWithCallisto = { + ...jupiter2, + relationships: { moons: { data: [{ type: 'moon', id: 'callisto' }] } } + }; + + const livePlanet = cache.liveQuery(q => + q.findRecord({ type: 'planet', id: 'jupiter' }) + ); + const livePlanets = cache.liveQuery(q => q.findRecords('planet')); + const livePlanetMoons = cache.liveQuery(q => + q.findRelatedRecords(jupiter, 'moons') + ); + const liveMoonPlanet = cache.liveQuery(q => + q.findRelatedRecord(callisto, 'planet') + ); + + interface Deferred { + promise?: Promise; + resolve?: () => void; + reject?: (message: string) => void; + } + function defer() { + let defer: Deferred = {}; + defer.promise = new Promise((resolve, reject) => { + defer.resolve = resolve; + defer.reject = message => reject(new Error(message)); + }); + return defer; + } + + let jupiterAdded = defer(); + let jupiterUpdated = defer(); + let callistoAdded = defer(); + let jupiterRemoved = defer(); + + function next() { + if (n === 1 && i === 1 && j === 0 && k === 0) { + jupiterAdded.resolve(); + } + if (n === 2 && i === 2 && j === 0 && k === 0) { + jupiterUpdated.resolve(); + } + if (n === 3 && i === 3 && j === 1 && k === 1) { + callistoAdded.resolve(); + } + if (n === 4 && i === 4 && j === 2 && k === 2) { + jupiterRemoved.resolve(); + } + } + + let n = 0; + let livePlanetUnsubscribe = livePlanet.subscribe(update => { + update + .query() + .then(result => { + n++; + if (n === 1) { + assert.deepEqual(result, jupiter, 'findRecord jupiter'); + } else if (n === 2) { + assert.deepEqual(result, jupiter2, 'findRecord jupiter2'); + } else if (n === 3) { + assert.deepEqual( + result, + jupiterWithCallisto, + 'findRecord jupiterWithCallisto' + ); + } else { + assert.ok(false, 'findRecord should not execute'); + } + next(); + }) + .catch(error => { + n++; + if (n === 4) { + assert.ok( + error instanceof RecordNotFoundException, + 'findRecord not found' + ); + } else { + assert.ok(false, 'findRecord should not throw error'); + } + next(); + }); + }); + + let i = 0; + let livePlanetsUnsubscribe = livePlanets.subscribe(update => { + update + .query() + .then(result => { + i++; + if (i === 1) { + assert.deepEqual(result, [jupiter], 'findRecords [jupiter]'); + } else if (i === 2) { + assert.deepEqual(result, [jupiter2], 'findRecords [jupiter2]'); + } else if (i === 3) { + assert.deepEqual( + result, + [jupiterWithCallisto], + 'findRecords [jupiterWithCallisto]' + ); + } else if (i === 4) { + assert.deepEqual(result, [], 'findRecords []'); + } else { + assert.ok(false, 'findRecords should not execute'); + } + next(); + }) + .catch(() => { + assert.ok(false, 'findRecords should not throw error'); + }); + }); + + let j = 0; + let livePlanetMoonsUnsubscribe = livePlanetMoons.subscribe(update => { + update + .query() + .then(result => { + j++; + if (j === 1) { + assert.deepEqual( + result, + [callisto], + 'findRelatedRecords jupiter.moons => [callisto]' + ); + } else { + assert.ok(false, 'findRelatedRecords should not execute'); + } + next(); + }) + .catch(error => { + j++; + if (j === 2) { + assert.ok( + error instanceof RecordNotFoundException, + 'findRelatedRecords not found' + ); + } else { + assert.ok(false, 'findRelatedRecords should not throw error'); + } + next(); + }); + }); + + let k = 0; + let liveMoonPlanetUnsubscribe = liveMoonPlanet.subscribe(update => { + update + .query() + .then(result => { + k++; + if (k === 1) { + assert.deepEqual( + result, + jupiterWithCallisto, + 'findRelatedRecord callisto.planet => jupiter' + ); + } else if (k === 2) { + assert.deepEqual( + result, + null, + 'findRelatedRecord callisto.planet => null' + ); + } else { + assert.ok(false, 'findRelatedRecord should not execute'); + } + next(); + }) + .catch(() => { + assert.ok(false, 'findRelatedRecord should not throw error'); + }); + }); + + setTimeout(() => { + jupiterAdded.reject('reject jupiterAdded'); + jupiterUpdated.reject('reject jupiterUpdated'); + callistoAdded.reject('reject callistoAdded'); + jupiterRemoved.reject('reject jupiterRemoved'); + }, 500); + + await cache.patch(t => t.addRecord(jupiter)); + await jupiterAdded.promise; + + await cache.patch(t => t.updateRecord(jupiter2)); + await jupiterUpdated.promise; + + await cache.patch(t => t.addRecord(callisto)); + await callistoAdded.promise; + + await cache.patch(t => t.removeRecord(jupiter)); + await jupiterRemoved.promise; + + assert.expect(16); + assert.equal(n, 4, 'findRecord should run 4 times'); + assert.equal(i, 4, 'findRecords should run 4 times'); + assert.equal(j, 2, 'findRelatedRecords should run 2 times'); + assert.equal(k, 2, 'findRelatedRecord should run 2 times'); + + livePlanetUnsubscribe(); + livePlanetsUnsubscribe(); + livePlanetMoonsUnsubscribe(); + liveMoonPlanetUnsubscribe(); + + await cache.patch(t => + t.addRecord({ + type: 'planet', + id: 'mercury', + attributes: { + name: 'Mercury' + } + }) + ); + }); + + test('#liveQuery findRecords', async function(assert) { + let cache = new Cache({ schema, keyMap }); + + const planets: Record[] = [ + { + id: 'planet1', + type: 'planet', + attributes: { name: 'Planet 1' } + }, + { + id: 'planet2', + type: 'planet', + attributes: { name: 'Planet 2' } + }, + { + id: 'planet3', + type: 'planet', + attributes: { name: 'Planet 3' } + } + ]; + + const livePlanets = cache.liveQuery(q => q.findRecords('planet')); + + let i = 0; + cache.on('patch', () => i++); + + const done = assert.async(); + livePlanets.subscribe(async update => { + const result = await update.query(); + assert.deepEqual(result, planets); + assert.equal(i, 3); + done(); + }); + + cache.patch(t => planets.map(planet => t.addRecord(planet))); + assert.expect(2); + }); }); diff --git a/packages/@orbit/record-cache/test/sync-record-cache-test.ts b/packages/@orbit/record-cache/test/sync-record-cache-test.ts index b84e513df..6ad7d53f6 100644 --- a/packages/@orbit/record-cache/test/sync-record-cache-test.ts +++ b/packages/@orbit/record-cache/test/sync-record-cache-test.ts @@ -3903,4 +3903,268 @@ module('SyncRecordCache', function(hooks) { [jupiter, mars] ); }); + + test('#liveQuery', async function(assert) { + let cache = new Cache({ schema, keyMap }); + + const jupiter: Record = { + id: 'jupiter', + type: 'planet', + attributes: { name: 'Jupiter' } + }; + + const jupiter2 = { + ...jupiter, + attributes: { name: 'Jupiter 2' } + }; + + const callisto: Record = { + id: 'callisto', + type: 'moon', + attributes: { name: 'Callisto' }, + relationships: { planet: { data: { type: 'planet', id: 'jupiter' } } } + }; + + const jupiterWithCallisto = { + ...jupiter2, + relationships: { moons: { data: [{ type: 'moon', id: 'callisto' }] } } + }; + + const livePlanet = cache.liveQuery(q => + q.findRecord({ type: 'planet', id: 'jupiter' }) + ); + const livePlanets = cache.liveQuery(q => q.findRecords('planet')); + const livePlanetMoons = cache.liveQuery(q => + q.findRelatedRecords(jupiter, 'moons') + ); + const liveMoonPlanet = cache.liveQuery(q => + q.findRelatedRecord(callisto, 'planet') + ); + + interface Deferred { + promise?: Promise; + resolve?: () => void; + reject?: (message: string) => void; + } + function defer() { + let defer: Deferred = {}; + defer.promise = new Promise((resolve, reject) => { + defer.resolve = resolve; + defer.reject = message => reject(new Error(message)); + }); + return defer; + } + + let jupiterAdded = defer(); + let jupiterUpdated = defer(); + let callistoAdded = defer(); + let jupiterRemoved = defer(); + + function next() { + if (n === 1 && i === 1 && j === 0 && k === 0) { + jupiterAdded.resolve(); + } + if (n === 2 && i === 2 && j === 0 && k === 0) { + jupiterUpdated.resolve(); + } + if (n === 3 && i === 3 && j === 1 && k === 1) { + callistoAdded.resolve(); + } + if (n === 4 && i === 4 && j === 2 && k === 2) { + jupiterRemoved.resolve(); + } + } + + let n = 0; + let livePlanetUnsubscribe = livePlanet.subscribe(update => { + n++; + try { + const result = update.query(); + + if (n === 1) { + assert.deepEqual(result, jupiter, 'findRecord jupiter'); + } else if (n === 2) { + assert.deepEqual(result, jupiter2, 'findRecord jupiter2'); + } else if (n === 3) { + assert.deepEqual( + result, + jupiterWithCallisto, + 'findRecord jupiterWithCallisto' + ); + } else { + assert.ok(false, 'findRecord should not execute'); + } + } catch (error) { + if (n === 4) { + assert.ok( + error instanceof RecordNotFoundException, + 'findRecord not found' + ); + } else { + assert.ok(false, 'findRecord should not throw error'); + } + } + next(); + }); + + let i = 0; + let livePlanetsUnsubscribe = livePlanets.subscribe(update => { + i++; + try { + const result = update.query(); + + if (i === 1) { + assert.deepEqual(result, [jupiter], 'findRecords [jupiter]'); + } else if (i === 2) { + assert.deepEqual(result, [jupiter2], 'findRecords [jupiter2]'); + } else if (i === 3) { + assert.deepEqual( + result, + [jupiterWithCallisto], + 'findRecords [jupiterWithCallisto]' + ); + } else if (i === 4) { + assert.deepEqual(result, [], 'findRecords []'); + } else { + assert.ok(false, 'findRecords should not execute'); + } + } catch { + assert.ok(false, 'findRecords should not throw error'); + } + next(); + }); + + let j = 0; + let livePlanetMoonsUnsubscribe = livePlanetMoons.subscribe(update => { + j++; + try { + const result = update.query(); + + if (j === 1) { + assert.deepEqual( + result, + [callisto], + 'findRelatedRecords jupiter.moons => [callisto]' + ); + } else { + assert.ok(false, 'findRelatedRecords should not execute'); + } + } catch (error) { + if (j === 2) { + assert.ok( + error instanceof RecordNotFoundException, + 'findRelatedRecords not found' + ); + } else { + assert.ok(false, 'findRelatedRecords should not throw error'); + } + } + next(); + }); + + let k = 0; + let liveMoonPlanetUnsubscribe = liveMoonPlanet.subscribe(update => { + k++; + try { + const result = update.query(); + + if (k === 1) { + assert.deepEqual( + result, + jupiterWithCallisto, + 'findRelatedRecord callisto.planet => jupiter' + ); + } else if (k === 2) { + assert.deepEqual( + result, + null, + 'findRelatedRecord callisto.planet => null' + ); + } else { + assert.ok(false, 'findRelatedRecord should not execute'); + } + } catch { + assert.ok(false, 'findRelatedRecord should not throw error'); + } + next(); + }); + + setTimeout(() => { + jupiterAdded.reject('reject jupiterAdded'); + jupiterUpdated.reject('reject jupiterUpdated'); + callistoAdded.reject('reject callistoAdded'); + jupiterRemoved.reject('reject jupiterRemoved'); + }, 500); + + cache.patch(t => t.addRecord(jupiter)); + await jupiterAdded.promise; + + cache.patch(t => t.updateRecord(jupiter2)); + await jupiterUpdated.promise; + + cache.patch(t => t.addRecord(callisto)); + await callistoAdded.promise; + + cache.patch(t => t.removeRecord(jupiter)); + await jupiterRemoved.promise; + + assert.expect(16); + assert.equal(n, 4, 'findRecord should run 4 times'); + assert.equal(i, 4, 'findRecords should run 4 times'); + assert.equal(j, 2, 'findRelatedRecords should run 2 times'); + assert.equal(k, 2, 'findRelatedRecord should run 2 times'); + + livePlanetUnsubscribe(); + livePlanetsUnsubscribe(); + livePlanetMoonsUnsubscribe(); + liveMoonPlanetUnsubscribe(); + + cache.patch(t => + t.addRecord({ + type: 'planet', + id: 'mercury', + attributes: { + name: 'Mercury' + } + }) + ); + }); + + test('#liveQuery findRecords', async function(assert) { + let cache = new Cache({ schema, keyMap }); + + const planets: Record[] = [ + { + id: 'planet1', + type: 'planet', + attributes: { name: 'Planet 1' } + }, + { + id: 'planet2', + type: 'planet', + attributes: { name: 'Planet 2' } + }, + { + id: 'planet3', + type: 'planet', + attributes: { name: 'Planet 3' } + } + ]; + + const livePlanets = cache.liveQuery(q => q.findRecords('planet')); + + let i = 0; + cache.on('patch', () => i++); + + const done = assert.async(); + livePlanets.subscribe(update => { + const result = update.query(); + assert.deepEqual(result, planets); + assert.equal(i, 3); + done(); + }); + + cache.patch(t => planets.map(planet => t.addRecord(planet))); + assert.expect(2); + }); });