import { Computed, react, RESET_VALUE, transact } from '@tldraw/state' import { BaseRecord, RecordId } from '../BaseRecord' import { createMigrationSequence } from '../migrate' import { createRecordType } from '../RecordType' import { CollectionDiff, RecordsDiff, Store } from '../Store' import { StoreSchema } from '../StoreSchema' interface Book extends BaseRecord<'book', RecordId> { title: string author: RecordId numPages: number } const Book = createRecordType('book', { validator: { validate: (book) => book as Book }, scope: 'document', }) interface Author extends BaseRecord<'author', RecordId> { name: string isPseudonym: boolean } const Author = createRecordType('author', { validator: { validate: (author) => author as Author }, scope: 'document', }).withDefaultProperties(() => ({ isPseudonym: false, })) interface Visit extends BaseRecord<'visit', RecordId> { visitorName: string booksInBasket: RecordId[] } const Visit = createRecordType('visit', { validator: { validate: (visit) => visit as Visit }, scope: 'session', }).withDefaultProperties(() => ({ visitorName: 'Anonymous', booksInBasket: [], })) type LibraryType = Book | Author | Visit describe('Store', () => { let store: Store beforeEach(() => { store = new Store({ props: {}, schema: StoreSchema.create({ book: Book, author: Author, visit: Visit, }), }) }) it('allows records to be added', () => { store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })]) expect(store.query.records('author').get()).toEqual([ { id: 'author:tolkein', typeName: 'author', name: 'J.R.R Tolkein', isPseudonym: false }, ]) store.put([ { id: Book.createId('the-hobbit'), typeName: 'book', title: 'The Hobbit', numPages: 423, author: Author.createId('tolkein'), }, ]) expect(store.query.records('book').get()).toEqual([ { id: 'book:the-hobbit', typeName: 'book', title: 'The Hobbit', numPages: 423, author: 'author:tolkein', }, ]) }) describe('with history', () => { let authorHistory: Computed> let lastDiff: RecordsDiff[] | typeof RESET_VALUE = 'undefined' as any beforeEach(() => { authorHistory = store.query.filterHistory('author') react('', (lastReactedEpoch) => { lastDiff = authorHistory.getDiffSince(lastReactedEpoch) }) expect(lastDiff!).toBe(RESET_VALUE) }) it('allows listening to the change history', () => { store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })]) expect(lastDiff!).toMatchInlineSnapshot(` [ { "added": { "author:tolkein": { "id": "author:tolkein", "isPseudonym": false, "name": "J.R.R Tolkein", "typeName": "author", }, }, "removed": {}, "updated": {}, }, ] `) store.update(Author.createId('tolkein'), (r) => ({ ...r, name: 'Jimmy Tolks' })) expect(lastDiff!).toMatchInlineSnapshot(` [ { "added": {}, "removed": {}, "updated": { "author:tolkein": [ { "id": "author:tolkein", "isPseudonym": false, "name": "J.R.R Tolkein", "typeName": "author", }, { "id": "author:tolkein", "isPseudonym": false, "name": "Jimmy Tolks", "typeName": "author", }, ], }, }, ] `) store.remove([Author.createId('tolkein')]) expect(lastDiff!).toMatchInlineSnapshot(` [ { "added": {}, "removed": { "author:tolkein": { "id": "author:tolkein", "isPseudonym": false, "name": "Jimmy Tolks", "typeName": "author", }, }, "updated": {}, }, ] `) transact(() => { store.put([ Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }), Author.create({ name: 'David Foster Wallace', id: Author.createId('dfw') }), Author.create({ name: 'Cynan Jones', id: Author.createId('cj') }), ]) store.update(Author.createId('tolkein'), (r) => ({ ...r, name: 'Jimmy Tolks' })) store.update(Author.createId('cj'), (r) => ({ ...r, name: 'Carter, Jimmy' })) }) expect(lastDiff!).toMatchInlineSnapshot(` [ { "added": { "author:cj": { "id": "author:cj", "isPseudonym": false, "name": "Carter, Jimmy", "typeName": "author", }, "author:dfw": { "id": "author:dfw", "isPseudonym": false, "name": "David Foster Wallace", "typeName": "author", }, "author:tolkein": { "id": "author:tolkein", "isPseudonym": false, "name": "Jimmy Tolks", "typeName": "author", }, }, "removed": {}, "updated": {}, }, ] `) }) }) it('allows adding onAfterChange callbacks that see the final state of the world', () => { /* ADDING */ store.onAfterCreate = jest.fn((current) => { expect(current).toEqual( Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }) ) expect([...store.query.ids('author').get()]).toEqual([Author.createId('tolkein')]) }) store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })]) expect(store.onAfterCreate).toHaveBeenCalledTimes(1) /* UPDATING */ store.onAfterChange = jest.fn((prev, current) => { if (prev.typeName === 'author' && current.typeName === 'author') { expect(prev.name).toBe('J.R.R Tolkein') expect(current.name).toBe('Butch Cassidy') expect(store.get(Author.createId('tolkein'))!.name).toBe('Butch Cassidy') } }) store.update(Author.createId('tolkein'), (r) => ({ ...r, name: 'Butch Cassidy' })) expect(store.onAfterChange).toHaveBeenCalledTimes(1) /* REMOVING */ store.onAfterDelete = jest.fn((prev) => { if (prev.typeName === 'author') { expect(prev.name).toBe('Butch Cassidy') } }) store.remove([Author.createId('tolkein')]) expect(store.onAfterDelete).toHaveBeenCalledTimes(1) }) it('allows finding and filtering records with a predicate', () => { store.put([ Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }), Author.create({ name: 'James McAvoy', id: Author.createId('mcavoy') }), Author.create({ name: 'Butch Cassidy', id: Author.createId('cassidy') }), Author.create({ name: 'Cynan Jones', id: Author.createId('cj') }), Author.create({ name: 'David Foster Wallace', id: Author.createId('dfw') }), ]) const Js = store.query .records('author') .get() .filter((r) => r.name.startsWith('J')) expect(Js.map((j) => j.name).sort()).toEqual(['J.R.R Tolkein', 'James McAvoy']) const david = store.query .records('author') .get() .find((r) => r.name.startsWith('David')) expect(david?.name).toBe('David Foster Wallace') }) it('allows keeping track of the ids of a particular type', () => { let lastIdDiff: CollectionDiff>[] | RESET_VALUE = [] const authorIds = store.query.ids('author') react('', (lastReactedEpoch) => { lastIdDiff = authorIds.getDiffSince(lastReactedEpoch) }) expect(lastIdDiff).toBe(RESET_VALUE) store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })]) expect(lastIdDiff).toMatchInlineSnapshot(` [ { "added": Set { "author:tolkein", }, }, ] `) transact(() => { store.put([Author.create({ name: 'James McAvoy', id: Author.createId('mcavoy') })]) store.put([Author.create({ name: 'Butch Cassidy', id: Author.createId('cassidy') })]) store.remove([Author.createId('tolkein')]) }) expect(lastIdDiff).toMatchInlineSnapshot(` [ { "added": Set { "author:mcavoy", "author:cassidy", }, "removed": Set { "author:tolkein", }, }, ] `) }) it('supports listening for changes to the whole store', async () => { const listener = jest.fn() store.listen(listener) transact(() => { store.put([ Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }), Author.create({ name: 'James McAvoy', id: Author.createId('mcavoy') }), Author.create({ name: 'Butch Cassidy', id: Author.createId('cassidy') }), Book.create({ title: 'The Hobbit', id: Book.createId('hobbit'), author: Author.createId('tolkein'), numPages: 300, }), ]) store.put([ Book.create({ title: 'The Lord of the Rings', id: Book.createId('lotr'), author: Author.createId('tolkein'), numPages: 1000, }), ]) }) await new Promise((resolve) => requestAnimationFrame(resolve)) expect(listener).toHaveBeenCalledTimes(1) expect(listener.mock.lastCall[0]).toMatchInlineSnapshot(` { "changes": { "added": { "author:cassidy": { "id": "author:cassidy", "isPseudonym": false, "name": "Butch Cassidy", "typeName": "author", }, "author:mcavoy": { "id": "author:mcavoy", "isPseudonym": false, "name": "James McAvoy", "typeName": "author", }, "author:tolkein": { "id": "author:tolkein", "isPseudonym": false, "name": "J.R.R Tolkein", "typeName": "author", }, "book:hobbit": { "author": "author:tolkein", "id": "book:hobbit", "numPages": 300, "title": "The Hobbit", "typeName": "book", }, "book:lotr": { "author": "author:tolkein", "id": "book:lotr", "numPages": 1000, "title": "The Lord of the Rings", "typeName": "book", }, }, "removed": {}, "updated": {}, }, "source": "user", } `) transact(() => { store.update(Author.createId('tolkein'), (author) => ({ ...author, name: 'Jimmy Tolks', })) store.update(Book.createId('lotr'), (book) => ({ ...book, numPages: 42 })) }) await new Promise((resolve) => requestAnimationFrame(resolve)) expect(listener).toHaveBeenCalledTimes(2) expect(listener.mock.lastCall[0]).toMatchInlineSnapshot(` { "changes": { "added": {}, "removed": {}, "updated": { "author:tolkein": [ { "id": "author:tolkein", "isPseudonym": false, "name": "J.R.R Tolkein", "typeName": "author", }, { "id": "author:tolkein", "isPseudonym": false, "name": "Jimmy Tolks", "typeName": "author", }, ], "book:lotr": [ { "author": "author:tolkein", "id": "book:lotr", "numPages": 1000, "title": "The Lord of the Rings", "typeName": "book", }, { "author": "author:tolkein", "id": "book:lotr", "numPages": 42, "title": "The Lord of the Rings", "typeName": "book", }, ], }, }, "source": "user", } `) transact(() => { store.update(Author.createId('mcavoy'), (author) => ({ ...author, name: 'Sookie Houseboat', })) store.remove([Book.createId('lotr')]) }) await new Promise((resolve) => requestAnimationFrame(resolve)) expect(listener).toHaveBeenCalledTimes(3) expect(listener.mock.lastCall[0]).toMatchInlineSnapshot(` { "changes": { "added": {}, "removed": { "book:lotr": { "author": "author:tolkein", "id": "book:lotr", "numPages": 42, "title": "The Lord of the Rings", "typeName": "book", }, }, "updated": { "author:mcavoy": [ { "id": "author:mcavoy", "isPseudonym": false, "name": "James McAvoy", "typeName": "author", }, { "id": "author:mcavoy", "isPseudonym": false, "name": "Sookie Houseboat", "typeName": "author", }, ], }, }, "source": "user", } `) }) it('supports filtering history by scope', () => { const listener = jest.fn() store.listen(listener, { scope: 'session', }) store.put([ Author.create({ name: 'J.R.R Tolkien', id: Author.createId('tolkien') }), Book.create({ title: 'The Hobbit', id: Book.createId('hobbit'), author: Author.createId('tolkien'), numPages: 300, }), ]) expect(listener).toHaveBeenCalledTimes(0) store.put([ Author.create({ name: 'J.D. Salinger', id: Author.createId('salinger') }), Visit.create({ id: Visit.createId('jimmy'), visitorName: 'Jimmy Beans' }), ]) expect(listener).toHaveBeenCalledTimes(1) expect(listener.mock.calls[0][0].changes).toMatchInlineSnapshot(` { "added": { "visit:jimmy": { "booksInBasket": [], "id": "visit:jimmy", "typeName": "visit", "visitorName": "Jimmy Beans", }, }, "removed": {}, "updated": {}, } `) }) it('supports filtering history by scope (2)', () => { const listener = jest.fn() store.listen(listener, { scope: 'document', }) store.put([ Author.create({ name: 'J.D. Salinger', id: Author.createId('salinger') }), Visit.create({ id: Visit.createId('jimmy'), visitorName: 'Jimmy Beans' }), ]) expect(listener).toHaveBeenCalledTimes(1) expect(listener.mock.calls[0][0].changes).toMatchInlineSnapshot(` { "added": { "author:salinger": { "id": "author:salinger", "isPseudonym": false, "name": "J.D. Salinger", "typeName": "author", }, }, "removed": {}, "updated": {}, } `) }) it('supports filtering history by source', () => { const listener = jest.fn() store.listen(listener, { source: 'remote', }) store.put([ Author.create({ name: 'J.D. Salinger', id: Author.createId('salinger') }), Visit.create({ id: Visit.createId('jimmy'), visitorName: 'Jimmy Beans' }), ]) expect(listener).toHaveBeenCalledTimes(0) store.mergeRemoteChanges(() => { store.put([ Author.create({ name: 'J.R.R Tolkien', id: Author.createId('tolkien') }), Book.create({ title: 'The Hobbit', id: Book.createId('hobbit'), author: Author.createId('tolkien'), numPages: 300, }), ]) }) expect(listener).toHaveBeenCalledTimes(1) expect(listener.mock.calls[0][0].changes).toMatchInlineSnapshot(` { "added": { "author:tolkien": { "id": "author:tolkien", "isPseudonym": false, "name": "J.R.R Tolkien", "typeName": "author", }, "book:hobbit": { "author": "author:tolkien", "id": "book:hobbit", "numPages": 300, "title": "The Hobbit", "typeName": "book", }, }, "removed": {}, "updated": {}, } `) }) it('supports filtering history by source (user)', () => { const listener = jest.fn() store.listen(listener, { source: 'user', }) store.mergeRemoteChanges(() => { store.put([ Author.create({ name: 'J.R.R Tolkien', id: Author.createId('tolkien') }), Book.create({ title: 'The Hobbit', id: Book.createId('hobbit'), author: Author.createId('tolkien'), numPages: 300, }), ]) }) expect(listener).toHaveBeenCalledTimes(0) store.put([ Author.create({ name: 'J.D. Salinger', id: Author.createId('salinger') }), Visit.create({ id: Visit.createId('jimmy'), visitorName: 'Jimmy Beans' }), ]) expect(listener).toHaveBeenCalledTimes(1) expect(listener.mock.calls[0][0].changes).toMatchInlineSnapshot(` { "added": { "author:salinger": { "id": "author:salinger", "isPseudonym": false, "name": "J.D. Salinger", "typeName": "author", }, "visit:jimmy": { "booksInBasket": [], "id": "visit:jimmy", "typeName": "visit", "visitorName": "Jimmy Beans", }, }, "removed": {}, "updated": {}, } `) }) it('does not keep global history if no listeners are attached', () => { store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })]) expect((store as any).historyAccumulator._history).toHaveLength(0) }) it('flushes history before attaching listeners', async () => { try { // @ts-expect-error globalThis.__FORCE_RAF_IN_TESTS__ = true store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })]) const firstListener = jest.fn() store.listen(firstListener) expect(firstListener).toHaveBeenCalledTimes(0) store.put([Author.create({ name: 'Chips McCoy', id: Author.createId('chips') })]) expect(firstListener).toHaveBeenCalledTimes(0) const secondListener = jest.fn() store.listen(secondListener) expect(firstListener).toHaveBeenCalledTimes(1) expect(secondListener).toHaveBeenCalledTimes(0) await new Promise((resolve) => requestAnimationFrame(resolve)) expect(firstListener).toHaveBeenCalledTimes(1) expect(secondListener).toHaveBeenCalledTimes(0) } finally { // @ts-expect-error globalThis.__FORCE_RAF_IN_TESTS__ = false } }) it('does not overwrite default properties with undefined', () => { const tolkein = Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }) expect(tolkein.isPseudonym).toBe(false) const harkaway = Author.create({ name: 'Nick Harkaway', id: Author.createId('harkaway'), isPseudonym: true, }) expect(harkaway.isPseudonym).toBe(true) const burns = Author.create({ name: 'Anna Burns', id: Author.createId('burns'), isPseudonym: undefined, }) expect(burns.isPseudonym).toBe(false) }) it('allows changed to be merged without triggering listeners', () => { const id = Author.createId('tolkein') store.put([Author.create({ name: 'J.R.R Tolkein', id })]) const listener = jest.fn() store.listen(listener) // Return the exact same value that came in store.update(id, (author) => author) expect(listener).not.toHaveBeenCalled() }) it('tells listeners the source of the changes so they can decide if they want to run or not', async () => { const listener = jest.fn() store.listen(listener) store.put([Author.create({ name: 'Jimmy Beans', id: Author.createId('jimmy') })]) await new Promise((resolve) => requestAnimationFrame(resolve)) expect(listener).toHaveBeenCalledTimes(1) expect(listener.mock.calls[0][0].source).toBe('user') store.mergeRemoteChanges(() => { store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })]) store.put([ Book.create({ title: 'The Hobbit', id: Book.createId('hobbit'), author: Author.createId('tolkein'), numPages: 300, }), ]) }) await new Promise((resolve) => requestAnimationFrame(resolve)) expect(listener).toHaveBeenCalledTimes(2) expect(listener.mock.calls[1][0].source).toBe('remote') store.put([Author.create({ name: 'Steve Ok', id: Author.createId('stever') })]) await new Promise((resolve) => requestAnimationFrame(resolve)) expect(listener).toHaveBeenCalledTimes(3) expect(listener.mock.calls[2][0].source).toBe('user') }) }) describe('snapshots', () => { let store: Store beforeEach(() => { store = new Store({ props: {}, schema: StoreSchema.create({ book: Book, author: Author, }), }) transact(() => { store.put([ Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }), Author.create({ name: 'James McAvoy', id: Author.createId('mcavoy') }), Author.create({ name: 'Butch Cassidy', id: Author.createId('cassidy') }), Book.create({ title: 'The Hobbit', id: Book.createId('hobbit'), author: Author.createId('tolkein'), numPages: 300, }), ]) store.put([ Book.create({ title: 'The Lord of the Rings', id: Book.createId('lotr'), author: Author.createId('tolkein'), numPages: 1000, }), ]) }) }) it('creates and loads a snapshot', () => { const serializedStore1 = store.serialize('all') const serializedSchema1 = store.schema.serialize() const snapshot1 = store.getSnapshot() const store2 = new Store({ props: {}, schema: StoreSchema.create({ book: Book, author: Author, }), }) store2.loadSnapshot(snapshot1) const serializedStore2 = store2.serialize('all') const serializedSchema2 = store2.schema.serialize() const snapshot2 = store2.getSnapshot() expect(serializedStore1).toEqual(serializedStore2) expect(serializedSchema1).toEqual(serializedSchema2) expect(snapshot1).toEqual(snapshot2) }) it('throws errors when loading a snapshot with a different schema', () => { const snapshot1 = store.getSnapshot() const store2 = new Store({ props: {}, schema: StoreSchema.create({ book: Book, // no author }), }) expect(() => { // @ts-expect-error store2.loadSnapshot(snapshot1) }).toThrowErrorMatchingInlineSnapshot(`"Missing definition for record type author"`) }) it('throws errors when loading a snapshot with a different schema', () => { const snapshot1 = store.getSnapshot() const store2 = new Store({ props: {}, schema: StoreSchema.create({ book: Book, }), }) expect(() => { store2.loadSnapshot(snapshot1 as any) }).toThrowErrorMatchingInlineSnapshot(`"Missing definition for record type author"`) }) it('migrates the snapshot', () => { const snapshot1 = store.getSnapshot() const up = jest.fn((s: any) => { s['book:lotr'].numPages = 42 }) expect((snapshot1.store as any)['book:lotr'].numPages).toBe(1000) const store2 = new Store({ props: {}, schema: StoreSchema.create( { book: Book, author: Author, }, { migrations: [ createMigrationSequence({ sequenceId: 'com.tldraw', retroactive: true, sequence: [ { id: `com.tldraw/1`, scope: 'store', up, }, ], }), ], } ), }) expect(() => { store2.loadSnapshot(snapshot1) }).not.toThrow() expect(up).toHaveBeenCalledTimes(1) expect(store2.get(Book.createId('lotr'))!.numPages).toBe(42) }) })