Repository: boardgameio/boardgame.io
Pull request #802 (2020-09-13 12:55:25)

fix: FlatFile.listGames filter
#### Checklist

* [x] Use a separate branch in your local repo (not `master`).
* [x] Test coverage is 100% (or you have a story for why it's ok).


delucis: @Pong420 Thanks for this! Is it ready for review?
Pong420: yes

Patch:
diff --git a/docs/documentation/notable_projects.md b/docs/documentation/notable_projects.md
index 832f1bd..4cebd6c 100644
--- a/docs/documentation/notable_projects.md
+++ b/docs/documentation/notable_projects.md
@@ -22,7 +22,7 @@ List of notable projects using boardgame.io. This list is not exhaustive. Feel f
 
 - [Hex game](https://github.com/Korla/hexgame) - [play](https://korla.github.io/hexgame/build/) - Simple hexagonal board game.
 
-- [Pong240's Boardgame](https://github.com/Pong420/Boardgame) - [play](http://play-boardgame.herokuapp.com) - A project for building board games with React and boardgame.io.
+- [Pong420's Boardgame](https://github.com/Pong420/Boardgame) - [play](http://play-boardgame.herokuapp.com) - A project for building board games with React and boardgame.io.
 
 - [Territories](https://github.com/lehaSVV2009/territories) - [play](https://lehasvv2009.github.io/territories/) - Simple board game Territories.
 
diff --git a/src/server/db/flatfile.ts b/src/server/db/flatfile.ts
index 222e3b9..ac52efd 100644
--- a/src/server/db/flatfile.ts
+++ b/src/server/db/flatfile.ts
@@ -9,16 +9,22 @@ import { State, Server, LogEntry } from '../../types';
  * https://opensource.org/licenses/MIT.
  */
 
+interface InitOptions {
+  dir: string;
+  logging?: boolean;
+  ttl?: boolean;
+}
+
 /**
  * FlatFile data storage.
  */
 export class FlatFile extends StorageAPI.Async {
   private games: {
-    init: (opts: object) => Promise<void>;
+    init: (opts: InitOptions) => Promise<void>;
     setItem: (id: string, value: any) => Promise<any>;
     getItem: (id: string) => Promise<State | Server.MatchData | LogEntry[]>;
     removeItem: (id: string) => Promise<void>;
-    clear: () => {};
+    clear: () => void;
     keys: () => Promise<string[]>;
   };
   private dir: string;
@@ -26,15 +32,7 @@ export class FlatFile extends StorageAPI.Async {
   private ttl?: boolean;
   private fileQueues: { [key: string]: Promise<any> };
 
-  constructor({
-    dir,
-    logging,
-    ttl,
-  }: {
-    dir: string;
-    logging?: boolean;
-    ttl?: boolean;
-  }) {
+  constructor({ dir, logging, ttl }: InitOptions) {
     super();
     this.games = require('node-persist');
     this.dir = dir;
@@ -93,7 +91,7 @@ export class FlatFile extends StorageAPI.Async {
     matchID: string,
     opts: O
   ): Promise<StorageAPI.FetchResult<O>> {
-    let result = {} as StorageAPI.FetchFields;
+    const result = {} as StorageAPI.FetchFields;
 
     if (opts.state) {
       result.state = (await this.getItem(matchID)) as State;
@@ -139,7 +137,7 @@ export class FlatFile extends StorageAPI.Async {
   }
 
   async wipe(id: string) {
-    var keys = await this.games.keys();
+    const keys = await this.games.keys();
     if (!(keys.indexOf(id) > -1)) return;
 
     await this.removeItem(id);
@@ -148,12 +146,59 @@ export class FlatFile extends StorageAPI.Async {
     await this.removeItem(MetadataKey(id));
   }
 
-  async listGames(): Promise<string[]> {
+  async listGames(opts?: StorageAPI.ListGamesOpts): Promise<string[]> {
     const keys = await this.games.keys();
     const suffix = ':metadata';
-    return keys
-      .filter(k => k.endsWith(suffix))
-      .map(k => k.substring(0, k.length - suffix.length));
+
+    const arr = await Promise.all(
+      keys.map(async k => {
+        if (!k.endsWith(suffix)) {
+          return false;
+        }
+
+        const matchID = k.substring(0, k.length - suffix.length);
+
+        if (!opts) {
+          return matchID;
+        }
+
+        const game = await this.fetch(matchID, {
+          state: true,
+          metadata: true,
+        });
+
+        if (opts.gameName && opts.gameName !== game.metadata.gameName) {
+          return false;
+        }
+
+        if (opts.where !== undefined) {
+          if (typeof opts.where.isGameover !== 'undefined') {
+            const isGameover = typeof game.metadata.gameover !== 'undefined';
+            if (isGameover !== opts.where.isGameover) {
+              return false;
+            }
+          }
+
+          if (
+            typeof opts.where.updatedBefore !== 'undefined' &&
+            game.metadata.updatedAt >= opts.where.updatedBefore
+          ) {
+            return false;
+          }
+
+          if (
+            typeof opts.where.updatedAfter !== 'undefined' &&
+            game.metadata.updatedAt <= opts.where.updatedAfter
+          ) {
+            return false;
+          }
+        }
+
+        return matchID;
+      })
+    );
+
+    return arr.filter((r): r is string => typeof r === 'string');
   }
 }
 


Test patch:
diff --git a/src/server/db/flatfile.test.ts b/src/server/db/flatfile.test.ts
index a5aa2ca..c0f7ae3 100644
--- a/src/server/db/flatfile.test.ts
+++ b/src/server/db/flatfile.test.ts
@@ -94,4 +94,128 @@ describe('FlatFile', () => {
     const result = await db.fetch('gameID', { log: true });
     expect(result.log).toEqual([logEntry1, logEntry2]);
   });
+
+  describe('listGames', () => {
+    beforeEach(async () => {
+      const state: unknown = { a: 1 };
+
+      await db.createGame('gameID', {
+        initialState: state as State,
+        metadata: {
+          gameName: 'game1',
+          updatedAt: new Date(2020, 3).getTime(),
+        } as Server.MatchData,
+      });
+
+      await db.createGame('gameID2', {
+        initialState: state as State,
+        metadata: {
+          gameName: 'game1',
+          gameover: 'gameover',
+          updatedAt: new Date(2020, 5).getTime(),
+        } as Server.MatchData,
+      });
+
+      await db.createGame('gameID3', {
+        initialState: state as State,
+        metadata: {
+          gameName: 'game2',
+          updatedAt: new Date(2020, 4).getTime(),
+        } as Server.MatchData,
+      });
+    });
+
+    test('filter by gameName', async () => {
+      let keys = await db.listGames();
+      expect(keys).toEqual(
+        expect.arrayContaining(['gameID', 'gameID2', 'gameID3'])
+      );
+
+      keys = await db.listGames({ gameName: 'game1' });
+      expect(keys).toEqual(expect.arrayContaining(['gameID', 'gameID2']));
+
+      keys = await db.listGames({ gameName: 'game2' });
+      expect(keys).toEqual(['gameID3']);
+    });
+
+    test('filter by isGameover', async () => {
+      let keys = await db.listGames({});
+
+      expect(keys).toEqual(
+        expect.arrayContaining(['gameID', 'gameID2', 'gameID3'])
+      );
+
+      keys = await db.listGames({ where: { isGameover: true } });
+      expect(keys).toEqual(['gameID2']);
+
+      keys = await db.listGames({ where: { isGameover: false } });
+      expect(keys).toEqual(expect.arrayContaining(['gameID', 'gameID3']));
+    });
+
+    test('filter by updatedBefore', async () => {
+      const timestamp = new Date(2020, 4);
+
+      let keys = await db.listGames({});
+      expect(keys).toEqual(
+        expect.arrayContaining(['gameID', 'gameID2', 'gameID3'])
+      );
+
+      keys = await db.listGames({
+        where: { updatedBefore: timestamp.getTime() },
+      });
+      expect(keys).toEqual(expect.arrayContaining(['gameID']));
+    });
+
+    test('filter by updatedAfter', async () => {
+      const timestamp = new Date(2020, 4);
+
+      let keys = await db.listGames({});
+      expect(keys).toEqual(
+        expect.arrayContaining(['gameID', 'gameID2', 'gameID3'])
+      );
+
+      keys = await db.listGames({
+        where: { updatedAfter: timestamp.getTime() },
+      });
+      expect(keys).toEqual(['gameID2']);
+    });
+
+    test('filter combined', async () => {
+      const timestamp = new Date(2020, 4);
+      const timestamp2 = new Date(2020, 2, 15);
+      let keys = await db.listGames({
+        gameName: 'chess',
+        where: { isGameover: true },
+      });
+      expect(keys).toEqual([]);
+
+      keys = await db.listGames({
+        where: { isGameover: true, updatedBefore: timestamp.getTime() },
+      });
+      expect(keys).toEqual([]);
+
+      keys = await db.listGames({
+        where: { isGameover: false, updatedBefore: timestamp.getTime() },
+      });
+      expect(keys).toEqual(['gameID']);
+
+      keys = await db.listGames({
+        where: { isGameover: true, updatedAfter: timestamp.getTime() },
+      });
+      expect(keys).toEqual(['gameID2']);
+
+      keys = await db.listGames({
+        where: { isGameover: false, updatedAfter: timestamp.getTime() },
+      });
+      expect(keys).toEqual([]);
+
+      keys = await db.listGames({
+        where: {
+          updatedBefore: timestamp.getTime(),
+          updatedAfter: timestamp2.getTime(),
+        },
+      });
+      expect(keys).toEqual(['gameID']);
+    });
+  });
 });