UNPKG

@tldraw/store

Version:

tldraw infinite canvas SDK (store).

1,568 lines (1,375 loc) • 40.2 kB
import { Computed, react, RESET_VALUE, transact } from '@tldraw/state' import { vi } from 'vitest' import { BaseRecord, RecordId } from '../BaseRecord' import { createMigrationSequence } from '../migrate' import { RecordsDiff, reverseRecordsDiff } from '../RecordsDiff' import { createRecordType } from '../RecordType' import { CollectionDiff, HistoryEntry, Store } from '../Store' import { StoreSchema } from '../StoreSchema' interface Book extends BaseRecord<'book', RecordId<Book>> { title: string author: RecordId<Author> numPages: number } const Book = createRecordType<Book>('book', { validator: { validate: (book) => book as Book }, scope: 'document', }) interface Author extends BaseRecord<'author', RecordId<Author>> { name: string isPseudonym: boolean } const Author = createRecordType<Author>('author', { validator: { validate: (author) => author as Author }, scope: 'document', }).withDefaultProperties(() => ({ isPseudonym: false, })) interface Visit extends BaseRecord<'visit', RecordId<Visit>> { visitorName: string booksInBasket: RecordId<Book>[] } const Visit = createRecordType<Visit>('visit', { validator: { validate: (visit) => visit as Visit }, scope: 'session', }).withDefaultProperties(() => ({ visitorName: 'Anonymous', booksInBasket: [], })) type LibraryType = Book | Author | Visit describe('Store', () => { let store: Store<Book | Author | Visit> beforeEach(() => { store = new Store({ props: {}, schema: StoreSchema.create<LibraryType>({ 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<number, RecordsDiff<Author>> let lastDiff: RecordsDiff<Author>[] | 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 */ const onAfterCreate = vi.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.sideEffects.registerAfterCreateHandler('author', onAfterCreate) store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })]) expect(onAfterCreate).toHaveBeenCalledTimes(1) /* UPDATING */ const onAfterChange = vi.fn((prev, current) => { 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.sideEffects.registerAfterChangeHandler('author', onAfterChange) store.update(Author.createId('tolkein'), (r) => ({ ...r, name: 'Butch Cassidy' })) expect(onAfterChange).toHaveBeenCalledTimes(1) /* REMOVING */ const onAfterDelete = vi.fn((prev) => { if (prev.typeName === 'author') { expect(prev.name).toBe('Butch Cassidy') } }) store.sideEffects.registerAfterDeleteHandler('author', onAfterDelete) store.remove([Author.createId('tolkein')]) expect(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<RecordId<Author>>[] | 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 = vi.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 = vi.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 = vi.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 = vi.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 = vi.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 = vi.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 = vi.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 = vi.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 = vi.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<Book | Author> beforeEach(() => { store = new Store({ props: {}, schema: StoreSchema.create<Book | Author>({ 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.getStoreSnapshot() const store2 = new Store({ props: {}, schema: StoreSchema.create<Book | Author>({ book: Book, author: Author, }), }) store2.loadStoreSnapshot(snapshot1) const serializedStore2 = store2.serialize('all') const serializedSchema2 = store2.schema.serialize() const snapshot2 = store2.getStoreSnapshot() 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.getStoreSnapshot() const store2 = new Store({ props: {}, schema: StoreSchema.create<Book>({ book: Book, // no author }), }) expect(() => { // @ts-expect-error store2.loadStoreSnapshot(snapshot1) }).toThrowErrorMatchingInlineSnapshot(`[Error: Missing definition for record type author]`) }) it('throws errors when loading a snapshot with a different schema', () => { const snapshot1 = store.getStoreSnapshot() const store2 = new Store({ props: {}, schema: StoreSchema.create<Book>({ book: Book, }), }) expect(() => { store2.loadStoreSnapshot(snapshot1 as any) }).toThrowErrorMatchingInlineSnapshot(`[Error: Missing definition for record type author]`) }) it('migrates the snapshot', () => { const snapshot1 = store.getStoreSnapshot() const up = vi.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 | Author>( { book: Book, author: Author, }, { migrations: [ createMigrationSequence({ sequenceId: 'com.tldraw', retroactive: true, sequence: [ { id: `com.tldraw/1`, scope: 'store', up, }, ], }), ], } ), }) expect(() => { store2.loadStoreSnapshot(snapshot1) }).not.toThrow() expect(up).toHaveBeenCalledTimes(1) expect(store2.get(Book.createId('lotr'))!.numPages).toBe(42) }) it('migrates the snapshot with storage scope', () => { const snapshot1 = store.getStoreSnapshot() const up = vi.fn((storage: any) => { const book = storage.get('book:lotr') storage.set('book:lotr', { ...book, numPages: 42 }) }) expect((snapshot1.store as any)['book:lotr'].numPages).toBe(1000) const store2 = new Store({ props: {}, schema: StoreSchema.create<Book | Author>( { book: Book, author: Author, }, { migrations: [ createMigrationSequence({ sequenceId: 'com.tldraw', retroactive: true, sequence: [ { id: `com.tldraw/1`, scope: 'storage', up, }, ], }), ], } ), }) expect(() => { store2.loadStoreSnapshot(snapshot1) }).not.toThrow() expect(up).toHaveBeenCalledTimes(1) expect(store2.get(Book.createId('lotr'))!.numPages).toBe(42) }) it('storage scope migration can delete records', () => { const snapshot1 = store.getStoreSnapshot() const up = vi.fn((storage: any) => { storage.delete('author:mcavoy') }) expect((snapshot1.store as any)['author:mcavoy']).toBeDefined() const store2 = new Store({ props: {}, schema: StoreSchema.create<Book | Author>( { book: Book, author: Author, }, { migrations: [ createMigrationSequence({ sequenceId: 'com.tldraw', retroactive: true, sequence: [ { id: `com.tldraw/1`, scope: 'storage', up, }, ], }), ], } ), }) expect(() => { store2.loadStoreSnapshot(snapshot1) }).not.toThrow() expect(up).toHaveBeenCalledTimes(1) expect(store2.get(Author.createId('mcavoy'))).toBeUndefined() }) it('storage scope migration can iterate records', () => { const snapshot1 = store.getStoreSnapshot() const up = vi.fn((storage: any) => { for (const [id, record] of storage.entries()) { if (record.typeName === 'book') { storage.set(id, { ...record, numPages: record.numPages + 100 }) } } }) expect((snapshot1.store as any)['book:lotr'].numPages).toBe(1000) expect((snapshot1.store as any)['book:hobbit'].numPages).toBe(300) const store2 = new Store({ props: {}, schema: StoreSchema.create<Book | Author>( { book: Book, author: Author, }, { migrations: [ createMigrationSequence({ sequenceId: 'com.tldraw', retroactive: true, sequence: [ { id: `com.tldraw/1`, scope: 'storage', up, }, ], }), ], } ), }) expect(() => { store2.loadStoreSnapshot(snapshot1) }).not.toThrow() expect(up).toHaveBeenCalledTimes(1) expect(store2.get(Book.createId('lotr'))!.numPages).toBe(1100) expect(store2.get(Book.createId('hobbit'))!.numPages).toBe(400) }) it('storage scope migration can use values() and keys()', () => { const snapshot1 = store.getStoreSnapshot() const keysCollected: string[] = [] const valuesCollected: any[] = [] const up = vi.fn((storage: any) => { for (const key of storage.keys()) { keysCollected.push(key) } for (const value of storage.values()) { valuesCollected.push(value) } }) const store2 = new Store({ props: {}, schema: StoreSchema.create<Book | Author>( { book: Book, author: Author, }, { migrations: [ createMigrationSequence({ sequenceId: 'com.tldraw', retroactive: true, sequence: [ { id: `com.tldraw/1`, scope: 'storage', up, }, ], }), ], } ), }) expect(() => { store2.loadStoreSnapshot(snapshot1) }).not.toThrow() expect(up).toHaveBeenCalledTimes(1) expect(keysCollected).toContain('book:lotr') expect(keysCollected).toContain('book:hobbit') expect(keysCollected).toContain('author:tolkein') expect(keysCollected).toContain('author:mcavoy') expect(keysCollected).toContain('author:cassidy') expect(valuesCollected.length).toBe(5) }) }) describe('diffs', () => { let store: Store<LibraryType> const authorId = Author.createId('tolkein') const bookId = Book.createId('hobbit') beforeEach(() => { store = new Store({ props: {}, schema: StoreSchema.create<LibraryType>({ book: Book, author: Author, visit: Visit, }), }) }) it('produces diffs from `extractingChanges`', () => { expect( store.extractingChanges(() => { store.put([Author.create({ name: 'J.R.R Tolkein', id: authorId })]) store.put([ Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 300 }), ]) }) ).toMatchInlineSnapshot(` { "added": { "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", }, }, "removed": {}, "updated": {}, } `) expect( store.extractingChanges(() => { store.remove([authorId]) store.update(bookId, (book) => ({ ...book, title: 'The Hobbit: There and Back Again' })) }) ).toMatchInlineSnapshot(` { "added": {}, "removed": { "author:tolkein": { "id": "author:tolkein", "isPseudonym": false, "name": "J.R.R Tolkein", "typeName": "author", }, }, "updated": { "book:hobbit": [ { "author": "author:tolkein", "id": "book:hobbit", "numPages": 300, "title": "The Hobbit", "typeName": "book", }, { "author": "author:tolkein", "id": "book:hobbit", "numPages": 300, "title": "The Hobbit: There and Back Again", "typeName": "book", }, ], }, } `) }) it('produces diffs from `addHistoryInterceptor`', () => { const diffs: any[] = [] const interceptor = vi.fn((diff) => diffs.push(diff)) store.addHistoryInterceptor(interceptor) store.put([ Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }), Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 300 }), ]) expect(interceptor).toHaveBeenCalledTimes(1) store.extractingChanges(() => { store.remove([authorId]) store.update(bookId, (book) => ({ ...book, title: 'The Hobbit: There and Back Again' })) }) expect(interceptor).toHaveBeenCalledTimes(3) expect(diffs).toMatchInlineSnapshot(` [ { "changes": { "added": { "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", }, }, "removed": {}, "updated": {}, }, "source": "user", }, { "changes": { "added": {}, "removed": { "author:tolkein": { "id": "author:tolkein", "isPseudonym": false, "name": "J.R.R Tolkein", "typeName": "author", }, }, "updated": {}, }, "source": "user", }, { "changes": { "added": {}, "removed": {}, "updated": { "book:hobbit": [ { "author": "author:tolkein", "id": "book:hobbit", "numPages": 300, "title": "The Hobbit", "typeName": "book", }, { "author": "author:tolkein", "id": "book:hobbit", "numPages": 300, "title": "The Hobbit: There and Back Again", "typeName": "book", }, ], }, }, "source": "user", }, ] `) }) it('can apply and invert diffs', () => { store.put([ Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }), Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 300 }), ]) const checkpoint1 = store.getStoreSnapshot() const forwardsDiff = store.extractingChanges(() => { store.remove([authorId]) store.update(bookId, (book) => ({ ...book, title: 'The Hobbit: There and Back Again' })) }) const checkpoint2 = store.getStoreSnapshot() store.applyDiff(reverseRecordsDiff(forwardsDiff)) expect(store.getStoreSnapshot()).toEqual(checkpoint1) store.applyDiff(forwardsDiff) expect(store.getStoreSnapshot()).toEqual(checkpoint2) }) }) describe('callbacks', () => { let store: Store<Book> let callbacks: any[] = [] const book1Id = Book.createId('darkness') const book1 = Book.create({ title: 'the left hand of darkness', id: book1Id, author: Author.createId('ursula'), numPages: 1, }) const book2Id = Book.createId('dispossessed') const book2 = Book.create({ title: 'the dispossessed', id: book2Id, author: Author.createId('ursula'), numPages: 1, }) let onAfterCreate: ReturnType<typeof vi.fn> let onAfterChange: ReturnType<typeof vi.fn> let onAfterDelete: ReturnType<typeof vi.fn> let onBeforeCreate: ReturnType<typeof vi.fn> let onBeforeChange: ReturnType<typeof vi.fn> let onBeforeDelete: ReturnType<typeof vi.fn> let onOperationComplete: ReturnType<typeof vi.fn> beforeEach(() => { store = new Store({ props: {}, schema: StoreSchema.create<Book>({ book: Book, }), }) onAfterCreate = vi.fn((record) => callbacks.push({ type: 'create', record })) onAfterChange = vi.fn((from, to) => callbacks.push({ type: 'change', from, to })) onAfterDelete = vi.fn((record) => callbacks.push({ type: 'delete', record })) onBeforeCreate = vi.fn((record) => record) onBeforeChange = vi.fn((_from, to) => to) onBeforeDelete = vi.fn((_record) => {}) onOperationComplete = vi.fn(() => callbacks.push({ type: 'complete' })) callbacks = [] store.sideEffects.registerAfterCreateHandler('book', onAfterCreate) store.sideEffects.registerAfterChangeHandler('book', onAfterChange) store.sideEffects.registerAfterDeleteHandler('book', onAfterDelete) store.sideEffects.registerBeforeCreateHandler('book', onBeforeCreate) store.sideEffects.registerBeforeChangeHandler('book', onBeforeChange) store.sideEffects.registerBeforeDeleteHandler('book', onBeforeDelete) store.sideEffects.registerOperationCompleteHandler(onOperationComplete) }) it('fires callbacks at the end of an `atomic` op', () => { store.atomic(() => { expect(callbacks).toHaveLength(0) store.put([book1, book2]) expect(callbacks).toHaveLength(0) }) expect(callbacks).toMatchObject([ { type: 'create', record: { id: book1Id } }, { type: 'create', record: { id: book2Id } }, { type: 'complete' }, ]) }) it('doesnt fire callback for a record created then deleted', () => { store.atomic(() => { store.put([book1]) store.remove([book1Id]) }) expect(callbacks).toMatchObject([{ type: 'complete' }]) }) it('bails out if too many callbacks are fired', () => { let limit = 10 onAfterCreate.mockImplementation((record: any) => { if (record.numPages < limit) { store.put([{ ...record, numPages: record.numPages + 1 }]) } }) onAfterChange.mockImplementation((from: any, to: any) => { if (to.numPages < limit) { store.put([{ ...to, numPages: to.numPages + 1 }]) } }) // this should be fine: store.put([book1]) expect(store.get(book1Id)!.numPages).toBe(limit) // if we increase the limit thought, it should crash: limit = 10000 store.clear() expect(() => { store.put([book2]) }).toThrowErrorMatchingInlineSnapshot( `[Error: Maximum store update depth exceeded, bailing out]` ) }) it('keeps firing operation complete callbacks until all are cleared', () => { // steps: // 0, 1, 2: after change increment pages // 3: after change, do nothing // 4: operation complete, increment pages by 1000 // 5, 6: after change increment pages // 7: after change, do nothing // 8: operation complete, do nothing // 9: done! let step = 0 store.put([book1]) onAfterChange.mockImplementation((prev: any, next: any) => { if ([0, 1, 2, 5, 6].includes(step)) { step++ store.put([{ ...next, numPages: next.numPages + 1 }]) } else if ([3, 7].includes(step)) { step++ } else { throw new Error(`Wrong step: ${step}`) } }) onOperationComplete.mockImplementation(() => { if (step === 4) { step++ const book = store.get(book1Id)! store.put([{ ...book, numPages: book.numPages + 1000 }]) } else if (step === 8) { step++ } else { throw new Error(`Wrong step: ${step}`) } }) store.put([{ ...book1, numPages: 2 }]) expect(store.get(book1Id)!.numPages).toBe(1007) expect(step).toBe(9) }) test('fired during mergeRemoteChanges are flushed at the end so that they end up receiving remote source but outputting user source changes', () => { const diffs: HistoryEntry<Book>[] = [] store.listen((entry) => { diffs.push(entry) }) const firstOrderEffectSources: string[] = [] store.sideEffects.registerAfterCreateHandler('book', (record, source) => { firstOrderEffectSources.push(source) if (record.title.startsWith('Harry Potter')) { store.put([ { ...record, title: record.title + ' is a really great book fr fr', }, ]) } }) const secondOrderEffectSources: string[] = [] store.sideEffects.registerAfterChangeHandler('book', (from, to, source) => { secondOrderEffectSources.push(source) }) store.mergeRemoteChanges(() => { store.put([ { ...book1, title: "Harry Potter and the Philosopher's Stone", }, ]) }) expect(firstOrderEffectSources).toMatchInlineSnapshot(` [ "remote", ] `) // recursive changes are always user expect(secondOrderEffectSources).toMatchInlineSnapshot(` [ "user", ] `) expect(diffs).toMatchInlineSnapshot(` [ { "changes": { "added": { "book:darkness": { "author": "author:ursula", "id": "book:darkness", "numPages": 1, "title": "Harry Potter and the Philosopher's Stone", "typeName": "book", }, }, "removed": {}, "updated": {}, }, "source": "remote", }, { "changes": { "added": {}, "removed": {}, "updated": { "book:darkness": [ { "author": "author:ursula", "id": "book:darkness", "numPages": 1, "title": "Harry Potter and the Philosopher's Stone", "typeName": "book", }, { "author": "author:ursula", "id": "book:darkness", "numPages": 1, "title": "Harry Potter and the Philosopher's Stone is a really great book fr fr", "typeName": "book", }, ], }, }, "source": "user", }, ] `) }) test('noop changes do not fire with store.atomic', () => { const book1A = book1 const book1B = { ...book1, title: book1.title + ' is a really great book fr fr', } const book1C = structuredClone(book1A) store.put([book1A]) store.atomic(() => { store.put([book1B]) store.put([book1C]) }) expect(onAfterChange).toHaveBeenCalledTimes(0) store.atomic(() => { store.put([book1B]) store.put([book1C]) store.put([book1B]) }) expect(onAfterChange).toHaveBeenCalledTimes(1) }) test('an atomic block with callbacks enabled can be overridden with an atomic block with callbacks disabled which causes the beforeCallbacks only to not run', () => { store.atomic(() => { store.put([book1]) store.atomic(() => { store.put([book2]) }, false) }) expect(onBeforeCreate).toHaveBeenCalledTimes(1) expect(onAfterCreate).toHaveBeenCalledTimes(2) store.atomic(() => { store.update(book1Id, (book) => ({ ...book, numPages: book.numPages + 1, })) store.atomic(() => { store.update(book2Id, (book) => ({ ...book, numPages: book.numPages + 1, })) }, false) }) expect(onBeforeChange).toHaveBeenCalledTimes(1) expect(onAfterChange).toHaveBeenCalledTimes(2) store.atomic(() => { store.remove([book1Id]) store.atomic(() => { store.remove([book2Id]) }, false) }) expect(onBeforeDelete).toHaveBeenCalledTimes(1) expect(onAfterDelete).toHaveBeenCalledTimes(2) }) })