/** * RED PHASE Tests for PGLite Collection * * Tests for PGLite integration with TanStack collections. * These tests should FAIL until implementation is complete. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { createPGLiteCollection, PGLiteCollection, PGLiteStore, } from './pglite-collection' import type { BaseRecord, PGLiteInterface, PGLiteCollectionOptions } from '../types' interface Note extends BaseRecord { id: number title: string content: string createdAt: string } /** * Mock PGLite implementation for testing */ function createMockPGLite(): PGLiteInterface { const data: Map = new Map() let nextId = 1 return { async query(sql: string, params?: unknown[]): Promise<{ rows: R[] }> { // Simple mock implementation if (sql.includes('SELECT')) { if (sql.includes('WHERE id =')) { const id = params?.[0] as number const item = data.get(id) return { rows: item ? [item as R] : [] } } return { rows: Array.from(data.values()) as R[] } } if (sql.includes('INSERT')) { const id = nextId++ const note: Note = { id, title: params?.[0] as string, content: params?.[1] as string, createdAt: new Date().toISOString(), } data.set(id, note) return { rows: [note as R] } } if (sql.includes('UPDATE')) { const id = params?.[params.length - 1] as number const existing = data.get(id) if (existing) { const updated = { ...existing } if (sql.includes('"title"') || sql.includes('title =')) { updated.title = params?.[0] as string } if (sql.includes('"content"') || sql.includes('content =')) { updated.content = params?.[0] as string } data.set(id, updated) return { rows: [updated as R] } } return { rows: [] } } if (sql.includes('DELETE')) { const id = params?.[0] as number data.delete(id) return { rows: [] } } return { rows: [] } }, async exec(_sql: string): Promise { // For CREATE TABLE etc. }, async close(): Promise { data.clear() }, } } describe('PGLiteCollection', () => { let mockPGLite: PGLiteInterface beforeEach(() => { mockPGLite = createMockPGLite() }) const createOptions = (overrides?: Partial>): PGLiteCollectionOptions => ({ id: 'notes', table: 'notes', pglite: mockPGLite, ...overrides, }) describe('createPGLiteCollection()', () => { it('should return a PGLiteCollection instance', () => { const collection = createPGLiteCollection(createOptions()) expect(collection).toBeInstanceOf(PGLiteCollection) }) it('should have the correct id', () => { const collection = createPGLiteCollection(createOptions({ id: 'my-notes' })) expect(collection.id).toBe('my-notes') }) it('should store the table name', () => { const collection = createPGLiteCollection(createOptions({ table: 'user_notes' })) expect(collection.table).toBe('user_notes') }) }) describe('initialize()', () => { it('should create table if autoCreateTable is true', async () => { const execSpy = vi.spyOn(mockPGLite, 'exec') const collection = createPGLiteCollection(createOptions({ autoCreateTable: true, tableSchema: 'id SERIAL PRIMARY KEY, title TEXT, content TEXT, createdAt TIMESTAMP', })) await collection.initialize() expect(execSpy).toHaveBeenCalledWith(expect.stringContaining('CREATE TABLE')) }) it('should not create table if autoCreateTable is false', async () => { const execSpy = vi.spyOn(mockPGLite, 'exec') const collection = createPGLiteCollection(createOptions({ autoCreateTable: false, })) await collection.initialize() expect(execSpy).not.toHaveBeenCalled() }) it('should set initialized to true', async () => { const collection = createPGLiteCollection(createOptions()) await collection.initialize() expect(collection.getSyncState().initialized).toBe(true) }) it('should load existing data', async () => { // Pre-populate mock await mockPGLite.query('INSERT INTO notes (title, content) VALUES ($1, $2)', ['Test', 'Content']) const collection = createPGLiteCollection(createOptions()) await collection.initialize() expect(collection.getAll().length).toBeGreaterThan(0) }) }) describe('getAll()', () => { it('should return empty array before initialize', () => { const collection = createPGLiteCollection(createOptions()) expect(collection.getAll()).toEqual([]) }) it('should return all items after initialize', async () => { await mockPGLite.query('INSERT INTO notes (title, content) VALUES ($1, $2)', ['Note 1', 'Content 1']) await mockPGLite.query('INSERT INTO notes (title, content) VALUES ($1, $2)', ['Note 2', 'Content 2']) const collection = createPGLiteCollection(createOptions()) await collection.initialize() expect(collection.getAll()).toHaveLength(2) }) }) describe('get()', () => { it('should return item by id', async () => { const { rows } = await mockPGLite.query('INSERT INTO notes (title, content) VALUES ($1, $2)', ['Test', 'Content']) const collection = createPGLiteCollection(createOptions()) await collection.initialize() const item = collection.get(rows[0]!.id) expect(item?.title).toBe('Test') }) it('should return undefined for non-existent id', async () => { const collection = createPGLiteCollection(createOptions()) await collection.initialize() expect(collection.get(999)).toBeUndefined() }) }) describe('insert()', () => { it('should insert item into PGLite', async () => { const querySpy = vi.spyOn(mockPGLite, 'query') const collection = createPGLiteCollection(createOptions()) await collection.initialize() await collection.insert({ title: 'New Note', content: 'New Content', createdAt: new Date().toISOString() }) expect(querySpy).toHaveBeenCalledWith( expect.stringContaining('INSERT'), expect.any(Array) ) }) it('should return the inserted item with id', async () => { const collection = createPGLiteCollection(createOptions()) await collection.initialize() const item = await collection.insert({ title: 'New Note', content: 'New Content', createdAt: new Date().toISOString() }) expect(item.id).toBeDefined() expect(item.title).toBe('New Note') }) it('should add item to local cache', async () => { const collection = createPGLiteCollection(createOptions()) await collection.initialize() const item = await collection.insert({ title: 'New Note', content: 'New Content', createdAt: new Date().toISOString() }) expect(collection.get(item.id)).toBeDefined() }) it('should notify subscribers', async () => { const collection = createPGLiteCollection(createOptions()) await collection.initialize() const callback = vi.fn() collection.subscribe(callback) await collection.insert({ title: 'New Note', content: 'New Content', createdAt: new Date().toISOString() }) expect(callback).toHaveBeenCalled() }) }) describe('update()', () => { it('should update item in PGLite', async () => { const collection = createPGLiteCollection(createOptions()) await collection.initialize() const item = await collection.insert({ title: 'Original', content: 'Content', createdAt: new Date().toISOString() }) const querySpy = vi.spyOn(mockPGLite, 'query') querySpy.mockClear() await collection.update(item.id, { title: 'Updated' }) expect(querySpy).toHaveBeenCalledWith( expect.stringContaining('UPDATE'), expect.any(Array) ) }) it('should return the updated item', async () => { const collection = createPGLiteCollection(createOptions()) await collection.initialize() const item = await collection.insert({ title: 'Original', content: 'Content', createdAt: new Date().toISOString() }) const updated = await collection.update(item.id, { title: 'Updated' }) expect(updated.title).toBe('Updated') }) it('should update local cache', async () => { const collection = createPGLiteCollection(createOptions()) await collection.initialize() const item = await collection.insert({ title: 'Original', content: 'Content', createdAt: new Date().toISOString() }) await collection.update(item.id, { title: 'Updated' }) expect(collection.get(item.id)?.title).toBe('Updated') }) it('should throw for non-existent id', async () => { const collection = createPGLiteCollection(createOptions()) await collection.initialize() await expect(collection.update(999, { title: 'Ghost' })).rejects.toThrow() }) it('should notify subscribers', async () => { const collection = createPGLiteCollection(createOptions()) await collection.initialize() const item = await collection.insert({ title: 'Original', content: 'Content', createdAt: new Date().toISOString() }) const callback = vi.fn() collection.subscribe(callback) await collection.update(item.id, { title: 'Updated' }) expect(callback).toHaveBeenCalled() }) }) describe('delete()', () => { it('should delete item from PGLite', async () => { const collection = createPGLiteCollection(createOptions()) await collection.initialize() const item = await collection.insert({ title: 'To Delete', content: 'Content', createdAt: new Date().toISOString() }) const querySpy = vi.spyOn(mockPGLite, 'query') querySpy.mockClear() await collection.delete(item.id) expect(querySpy).toHaveBeenCalledWith( expect.stringContaining('DELETE'), expect.any(Array) ) }) it('should remove item from local cache', async () => { const collection = createPGLiteCollection(createOptions()) await collection.initialize() const item = await collection.insert({ title: 'To Delete', content: 'Content', createdAt: new Date().toISOString() }) await collection.delete(item.id) expect(collection.get(item.id)).toBeUndefined() }) it('should throw for non-existent id', async () => { const collection = createPGLiteCollection(createOptions()) await collection.initialize() await expect(collection.delete(999)).rejects.toThrow() }) it('should notify subscribers', async () => { const collection = createPGLiteCollection(createOptions()) await collection.initialize() const item = await collection.insert({ title: 'To Delete', content: 'Content', createdAt: new Date().toISOString() }) const callback = vi.fn() collection.subscribe(callback) await collection.delete(item.id) expect(callback).toHaveBeenCalled() }) }) describe('subscribe()', () => { it('should return unsubscribe function', async () => { const collection = createPGLiteCollection(createOptions()) const unsubscribe = collection.subscribe(() => {}) expect(typeof unsubscribe).toBe('function') }) it('should not call callback after unsubscribe', async () => { const collection = createPGLiteCollection(createOptions()) await collection.initialize() const callback = vi.fn() const unsubscribe = collection.subscribe(callback) callback.mockClear() unsubscribe() await collection.insert({ title: 'Test', content: 'Content', createdAt: new Date().toISOString() }) expect(callback).not.toHaveBeenCalled() }) }) describe('getSyncState()', () => { it('should return initial state before initialize', () => { const collection = createPGLiteCollection(createOptions()) expect(collection.getSyncState()).toMatchObject({ connected: false, initialized: false, pendingCount: 0, }) }) it('should return connected state after initialize', async () => { const collection = createPGLiteCollection(createOptions()) await collection.initialize() expect(collection.getSyncState().connected).toBe(true) }) }) describe('rawQuery()', () => { it('should execute raw SQL queries', async () => { const collection = createPGLiteCollection(createOptions()) await collection.initialize() await collection.insert({ title: 'Test', content: 'Content', createdAt: new Date().toISOString() }) const result = await collection.rawQuery('SELECT * FROM notes WHERE title = $1', ['Test']) expect(result.rows).toHaveLength(1) }) }) describe('PGLite factory function support', () => { it('should accept a factory function for lazy initialization', async () => { const factory = vi.fn().mockResolvedValue(mockPGLite) const collection = createPGLiteCollection(createOptions({ pglite: factory, })) await collection.initialize() expect(factory).toHaveBeenCalled() }) }) }) describe('PGLiteStore', () => { let mockPGLite: PGLiteInterface beforeEach(() => { mockPGLite = createMockPGLite() }) it('should create store instance', () => { const store = new PGLiteStore({ pglite: mockPGLite }) expect(store).toBeInstanceOf(PGLiteStore) }) it('should register collections', () => { const store = new PGLiteStore({ pglite: mockPGLite }) const collection = store.registerCollection({ id: 'notes', table: 'notes', }) expect(collection).toBeInstanceOf(PGLiteCollection) }) it('should get registered collection', () => { const store = new PGLiteStore({ pglite: mockPGLite }) store.registerCollection({ id: 'notes', table: 'notes', }) const collection = store.getCollection('notes') expect(collection).toBeDefined() }) it('should return undefined for non-existent collection', () => { const store = new PGLiteStore({ pglite: mockPGLite }) expect(store.getCollection('ghost')).toBeUndefined() }) it('should list collection IDs', () => { const store = new PGLiteStore({ pglite: mockPGLite }) store.registerCollection({ id: 'notes', table: 'notes' }) store.registerCollection({ id: 'tasks', table: 'tasks' }) expect(store.getCollectionIds()).toEqual(['notes', 'tasks']) }) it('should initialize all collections', async () => { const store = new PGLiteStore({ pglite: mockPGLite }) const collection1 = store.registerCollection({ id: 'notes', table: 'notes' }) const collection2 = store.registerCollection({ id: 'tasks', table: 'tasks' }) const init1 = vi.spyOn(collection1, 'initialize') const init2 = vi.spyOn(collection2, 'initialize') await store.initializeAll() expect(init1).toHaveBeenCalled() expect(init2).toHaveBeenCalled() }) it('should dispose and close PGLite', async () => { const closeSpy = vi.spyOn(mockPGLite, 'close') const store = new PGLiteStore({ pglite: mockPGLite }) await store.dispose() expect(closeSpy).toHaveBeenCalled() }) it('should accept factory function for PGLite', async () => { const factory = vi.fn().mockResolvedValue(mockPGLite) const store = new PGLiteStore({ pglite: factory }) const collection = store.registerCollection({ id: 'notes', table: 'notes' }) await collection.initialize() expect(factory).toHaveBeenCalled() }) it('should clear collections on dispose', async () => { const store = new PGLiteStore({ pglite: mockPGLite }) store.registerCollection({ id: 'notes', table: 'notes' }) await store.dispose() expect(store.getCollectionIds()).toEqual([]) }) it('should handle dispose with factory function', async () => { // For factory function case, dispose should clear collections // even if factory was never called const factory = vi.fn().mockResolvedValue(mockPGLite) const store = new PGLiteStore({ pglite: factory }) store.registerCollection({ id: 'notes', table: 'notes' }) await store.dispose() // Collections should be cleared expect(store.getCollectionIds()).toEqual([]) }) }) describe('PGLiteCollection - Edge Cases', () => { let mockPGLite: PGLiteInterface beforeEach(() => { mockPGLite = createMockPGLite() }) it('should handle multiple subscribers', async () => { const collection = createPGLiteCollection({ id: 'notes', table: 'notes', pglite: mockPGLite, }) await collection.initialize() const callback1 = vi.fn() const callback2 = vi.fn() const callback3 = vi.fn() collection.subscribe(callback1) collection.subscribe(callback2) collection.subscribe(callback3) await collection.insert({ title: 'Test', content: 'Content', createdAt: new Date().toISOString() }) expect(callback1).toHaveBeenCalled() expect(callback2).toHaveBeenCalled() expect(callback3).toHaveBeenCalled() }) it('should pass items array to subscribers', async () => { const collection = createPGLiteCollection({ id: 'notes', table: 'notes', pglite: mockPGLite, }) await collection.initialize() let receivedItems: Note[] = [] collection.subscribe((items) => { receivedItems = items }) await collection.insert({ title: 'Test', content: 'Content', createdAt: new Date().toISOString() }) expect(receivedItems).toHaveLength(1) expect(receivedItems[0]!.title).toBe('Test') }) it('should return immutable sync state', async () => { const collection = createPGLiteCollection({ id: 'notes', table: 'notes', pglite: mockPGLite, }) const state1 = collection.getSyncState() await collection.initialize() const state2 = collection.getSyncState() expect(state1.initialized).toBe(false) expect(state2.initialized).toBe(true) }) it('should set lastSyncAt on initialize', async () => { const collection = createPGLiteCollection({ id: 'notes', table: 'notes', pglite: mockPGLite, }) const before = Date.now() await collection.initialize() const after = Date.now() const state = collection.getSyncState() expect(state.lastSyncAt).toBeGreaterThanOrEqual(before) expect(state.lastSyncAt).toBeLessThanOrEqual(after) }) it('should refresh data from PGLite', async () => { const collection = createPGLiteCollection({ id: 'notes', table: 'notes', pglite: mockPGLite, }) await collection.initialize() // Add items directly to mock await mockPGLite.query('INSERT INTO notes (title, content) VALUES ($1, $2)', ['External', 'Added']) // Refresh should pick up the change await collection.refresh() // Should see 1 item now (the one we added) expect(collection.getAll().length).toBeGreaterThan(0) }) it('should handle insert with provided id', async () => { const collection = createPGLiteCollection({ id: 'notes', table: 'notes', pglite: mockPGLite, }) await collection.initialize() // Note: our mock doesn't fully support custom IDs, but test the path const item = await collection.insert({ id: 999, title: 'Custom ID', content: 'Content', createdAt: new Date().toISOString() }) expect(item).toBeDefined() expect(item.title).toBe('Custom ID') }) }) describe('PGLiteStore - Multiple Collections', () => { let mockPGLite: PGLiteInterface beforeEach(() => { mockPGLite = createMockPGLite() }) interface Task extends BaseRecord { id: number description: string done: boolean } it('should support multiple collections with different types', () => { const store = new PGLiteStore({ pglite: mockPGLite }) const notes = store.registerCollection({ id: 'notes', table: 'notes' }) const tasks = store.registerCollection({ id: 'tasks', table: 'tasks' }) expect(notes.id).toBe('notes') expect(tasks.id).toBe('tasks') expect(store.getCollectionIds()).toEqual(['notes', 'tasks']) }) it('should replace collection with same id', () => { const store = new PGLiteStore({ pglite: mockPGLite }) store.registerCollection({ id: 'notes', table: 'notes_v1' }) const notes2 = store.registerCollection({ id: 'notes', table: 'notes_v2' }) expect(store.getCollectionIds()).toHaveLength(1) expect(store.getCollection('notes')).toBe(notes2) }) })