Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"eslint-plugin-simple-import-sort": "^10.0.0",
"fake-indexeddb": "^6.2.5",
"postcss": "^8.4.49",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.14",
Expand Down
165 changes: 165 additions & 0 deletions src/ui/idb.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import 'fake-indexeddb/auto'

import { afterEach, beforeEach, describe, expect, it } from 'vitest'

import { db } from './idb'

describe('IndexedDB Storage (Safari compatibility)', () => {
beforeEach(async () => {
// Clear database before each test
await db.samples.clear()
})

afterEach(async () => {
// Clean up
await db.samples.clear()
})

it('should store and retrieve ArrayBuffer with MIME type', async () => {
const originalData = 'test audio data'
const blob = new Blob([originalData], { type: 'audio/wav' })
const arrayBuffer = await blob.arrayBuffer()

// Store
await db.samples.put({
name: 'test.wav',
url: '/test.wav',
arrayBuffer,
mimeType: 'audio/wav',
})

// Retrieve
const retrieved = await db.samples.where('url').equals('/test.wav').first()

expect(retrieved).toBeDefined()
expect(retrieved?.mimeType).toBe('audio/wav')
expect(retrieved?.arrayBuffer).toBeInstanceOf(ArrayBuffer)

// Verify data integrity: convert back to Blob and check contents
const reconstructedBlob = new Blob([retrieved!.arrayBuffer], {

Check failure on line 39 in src/ui/idb.test.ts

View workflow job for this annotation

GitHub Actions / eslint

[eslint] reported by reviewdog 🐶 Forbidden non-null assertion. Raw Output: {"ruleId":"@typescript-eslint/no-non-null-assertion","severity":2,"message":"Forbidden non-null assertion.","line":39,"column":41,"nodeType":"TSNonNullExpression","messageId":"noNonNull","endLine":39,"endColumn":51,"suggestions":[{"messageId":"suggestOptionalChain","fix":{"range":[1110,1111],"text":"?"},"desc":"Consider using the optional chain operator `?.` instead. This operator includes runtime checks, so it is safer than the compile-only non-null assertion operator."}]}
type: retrieved!.mimeType,

Check failure on line 40 in src/ui/idb.test.ts

View workflow job for this annotation

GitHub Actions / eslint

[eslint] reported by reviewdog 🐶 Forbidden non-null assertion. Raw Output: {"ruleId":"@typescript-eslint/no-non-null-assertion","severity":2,"message":"Forbidden non-null assertion.","line":40,"column":13,"nodeType":"TSNonNullExpression","messageId":"noNonNull","endLine":40,"endColumn":23,"suggestions":[{"messageId":"suggestOptionalChain","fix":{"range":[1149,1150],"text":"?"},"desc":"Consider using the optional chain operator `?.` instead. This operator includes runtime checks, so it is safer than the compile-only non-null assertion operator."}]}
})
const text = await reconstructedBlob.text()
expect(text).toBe(originalData)
})

it('should handle empty ArrayBuffer', async () => {
const emptyBlob = new Blob([], { type: 'audio/wav' })
const arrayBuffer = await emptyBlob.arrayBuffer()

await db.samples.put({
name: 'empty.wav',
url: '/empty.wav',
arrayBuffer,
mimeType: 'audio/wav',
})

const retrieved = await db.samples.where('url').equals('/empty.wav').first()
expect(retrieved).toBeDefined()
expect(retrieved?.arrayBuffer.byteLength).toBe(0)
})

it('should handle large ArrayBuffer (simulating audio file)', async () => {
// Create a 1MB ArrayBuffer (typical small audio file)
const size = 1024 * 1024
const largeArray = new Uint8Array(size)
// Fill with some pattern to verify integrity
for (let i = 0; i < size; i++) {
largeArray[i] = i % 256
}

await db.samples.put({
name: 'large.wav',
url: '/large.wav',
arrayBuffer: largeArray.buffer,
mimeType: 'audio/wav',
})

const retrieved = await db.samples.where('url').equals('/large.wav').first()
expect(retrieved).toBeDefined()
expect(retrieved?.arrayBuffer.byteLength).toBe(size)

// Verify data integrity
const retrievedArray = new Uint8Array(retrieved!.arrayBuffer)

Check failure on line 83 in src/ui/idb.test.ts

View workflow job for this annotation

GitHub Actions / eslint

[eslint] reported by reviewdog 🐶 Forbidden non-null assertion. Raw Output: {"ruleId":"@typescript-eslint/no-non-null-assertion","severity":2,"message":"Forbidden non-null assertion.","line":83,"column":43,"nodeType":"TSNonNullExpression","messageId":"noNonNull","endLine":83,"endColumn":53,"suggestions":[{"messageId":"suggestOptionalChain","fix":{"range":[2476,2477],"text":"?"},"desc":"Consider using the optional chain operator `?.` instead. This operator includes runtime checks, so it is safer than the compile-only non-null assertion operator."}]}
expect(retrievedArray[0]).toBe(0)
expect(retrievedArray[255]).toBe(255)
expect(retrievedArray[256]).toBe(0)
})

it('should handle various MIME types', async () => {
const mimeTypes = ['audio/wav', 'audio/mpeg', 'audio/ogg', 'audio/webm', '']

for (const mimeType of mimeTypes) {
const blob = new Blob(['test'], { type: mimeType })
const arrayBuffer = await blob.arrayBuffer()

await db.samples.put({
name: `test-${mimeType || 'unknown'}.audio`,
url: `/test-${mimeType || 'unknown'}.audio`,
arrayBuffer,
mimeType: mimeType || 'audio/wav', // Default fallback
})
}

const allSamples = await db.samples.toArray()
expect(allSamples).toHaveLength(mimeTypes.length)
})

it('should support multiple concurrent writes', async () => {
const writePromises = Array.from({ length: 10 }, (_, i) => {
const blob = new Blob([`data-${i}`], { type: 'audio/wav' })
return blob.arrayBuffer().then((arrayBuffer) =>
db.samples.put({
name: `sample-${i}.wav`,
url: `/sample-${i}.wav`,
arrayBuffer,
mimeType: 'audio/wav',
}),
)
})

await Promise.all(writePromises)

const count = await db.samples.count()
expect(count).toBe(10)
})

it('should update existing records by URL', async () => {
const url = '/updateable.wav'

// Initial write
const blob1 = new Blob(['original'], { type: 'audio/wav' })
await db.samples.put({
name: 'original.wav',
url,
arrayBuffer: await blob1.arrayBuffer(),
mimeType: 'audio/wav',
})

// Update
const blob2 = new Blob(['updated'], { type: 'audio/mpeg' })
await db.samples.put({
name: 'updated.mp3',
url,
arrayBuffer: await blob2.arrayBuffer(),
mimeType: 'audio/mpeg',
})

const retrieved = await db.samples.where('url').equals(url).first()
expect(retrieved?.name).toBe('updated.mp3')
expect(retrieved?.mimeType).toBe('audio/mpeg')

const text = await new Blob([retrieved!.arrayBuffer]).text()

Check failure on line 152 in src/ui/idb.test.ts

View workflow job for this annotation

GitHub Actions / eslint

[eslint] reported by reviewdog 🐶 Forbidden non-null assertion. Raw Output: {"ruleId":"@typescript-eslint/no-non-null-assertion","severity":2,"message":"Forbidden non-null assertion.","line":152,"column":34,"nodeType":"TSNonNullExpression","messageId":"noNonNull","endLine":152,"endColumn":44,"suggestions":[{"messageId":"suggestOptionalChain","fix":{"range":[4561,4562],"text":"?"},"desc":"Consider using the optional chain operator `?.` instead. This operator includes runtime checks, so it is safer than the compile-only non-null assertion operator."}]}
expect(text).toBe('updated')

// Should only have one record
const count = await db.samples.count()
expect(count).toBe(1)
})
})

describe('Database migration', () => {
it('should have a "samples" table', () => {
expect(db.tables.map((t) => t.name)).toContain('samples')
})
})
18 changes: 15 additions & 3 deletions src/ui/idb.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,35 @@
import Dexie, { type EntityTable } from 'dexie'

Check warning on line 1 in src/ui/idb.ts

View workflow job for this annotation

GitHub Actions / eslint

[eslint] reported by reviewdog 🐶 Using exported name 'Dexie' as identifier for default import. Raw Output: {"ruleId":"import/no-named-as-default","severity":1,"message":"Using exported name 'Dexie' as identifier for default import.","line":1,"column":8,"nodeType":"ImportDefaultSpecifier","endLine":1,"endColumn":13}

interface SampleData {
id: number
name: string
url: string
blob: Blob
arrayBuffer: ArrayBuffer
mimeType: string
}

// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const db = new Dexie('SamplesDataDb') as Dexie & {
samples: EntityTable<
SampleData,
'id' // primary key (for the typings only)
'url' // primary key (for the typings only)
>
}

db.version(1).stores({
samples: '++id, name, url', // primary key and indexed keys (for the runtime!)
})

// v2:
// - use ArrayBuffer instead of Blob for Safari
// - use URL as primary key (since a query by URL is used to decide whether to fetch)
db.version(2)
.stores({
samples: 'url, name',
})
.upgrade(async (tx) => {
// Clear old blob-based data - samples will be re-downloaded with new schema
await tx.table('samples').clear()
})

export type { SampleData }
export { db }
76 changes: 75 additions & 1 deletion src/ui/load_samples.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,14 @@

it('should handle cached samples', async () => {
const mockBlob = new Blob(['cached audio data'])
const mockArrayBuffer = await mockBlob.arrayBuffer()

// Mock database returning cached data
mockDbFirst.mockResolvedValueOnce({
name: 'cached.wav',
url: '/samples/cached.wav',
blob: mockBlob,
arrayBuffer: mockArrayBuffer,
mimeType: 'audio/wav',
})

const sample = { name: 'cached.wav', url: '/samples/cached.wav' }
Expand All @@ -108,4 +110,76 @@
expect(fetch).not.toHaveBeenCalled()
expect(result.name).toBe('cached.wav')
})

it('should store blob as ArrayBuffer for Safari compatibility', async () => {
const mockBlob = new Blob(['audio data'], { type: 'audio/mpeg' })
const mockPut = vi.fn().mockResolvedValue(undefined)

mockFetch.mockResolvedValueOnce({
blob: () => Promise.resolve(mockBlob),
})

// Replace the mocked put to spy on what gets stored
vi.mocked(await import('./idb')).db.samples.put = mockPut

const sample = { name: 'test.mp3', url: '/samples/test.mp3' }
await loadSample(sample)

// Verify put was called with ArrayBuffer, not Blob
expect(mockPut).toHaveBeenCalledWith(
expect.objectContaining({
name: 'test.mp3',
url: '/samples/test.mp3',
arrayBuffer: expect.any(ArrayBuffer),

Check failure on line 133 in src/ui/load_samples.test.ts

View workflow job for this annotation

GitHub Actions / eslint

[eslint] reported by reviewdog 🐶 Unsafe assignment of an `any` value. Raw Output: {"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":133,"column":9,"nodeType":"Property","messageId":"anyAssignment","endLine":133,"endColumn":45}
mimeType: 'audio/mpeg',
}),
)

// Verify it's not storing a Blob
const putCall = mockPut.mock.calls[0]?.[0]

Check failure on line 139 in src/ui/load_samples.test.ts

View workflow job for this annotation

GitHub Actions / eslint

[eslint] reported by reviewdog 🐶 Unsafe assignment of an `any` value. Raw Output: {"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":139,"column":11,"nodeType":"VariableDeclarator","messageId":"anyAssignment","endLine":139,"endColumn":47}
expect(putCall).not.toHaveProperty('blob')
})

it('should store empty MIME type when blob.type is empty', async () => {
const mockBlob = new Blob(['audio data'], { type: '' })
const mockPut = vi.fn().mockResolvedValue(undefined)

mockFetch.mockResolvedValueOnce({
blob: () => Promise.resolve(mockBlob),
})

vi.mocked(await import('./idb')).db.samples.put = mockPut

const sample = { name: 'test.unknown', url: '/samples/test.unknown' }
await loadSample(sample)

// Should store whatever blob.type is (empty string in this case)
expect(mockPut).toHaveBeenCalledWith(
expect.objectContaining({
mimeType: '',
}),
)
})

it('should reconstruct blob from ArrayBuffer with correct MIME type', async () => {
const originalData = 'cached audio data'
const originalBlob = new Blob([originalData], { type: 'audio/ogg' })
const mockArrayBuffer = await originalBlob.arrayBuffer()

mockDbFirst.mockResolvedValueOnce({
name: 'cached.ogg',
url: '/samples/cached.ogg',
arrayBuffer: mockArrayBuffer,
mimeType: 'audio/ogg',
})

const sample = { name: 'cached.ogg', url: '/samples/cached.ogg' }
await loadSample(sample)

// Verify URL.createObjectURL was called (blob was reconstructed)
expect(global.URL.createObjectURL).toHaveBeenCalled()

Check failure on line 180 in src/ui/load_samples.test.ts

View workflow job for this annotation

GitHub Actions / eslint

[eslint] reported by reviewdog 🐶 Avoid referencing unbound methods which may cause unintentional scoping of `this`. If your function does not access `this`, you can annotate it with `this: void`, or consider using an arrow function instead. Raw Output: {"ruleId":"@typescript-eslint/unbound-method","severity":2,"message":"Avoid referencing unbound methods which may cause unintentional scoping of `this`.\nIf your function does not access `this`, you can annotate it with `this: void`, or consider using an arrow function instead.","line":180,"column":12,"nodeType":"MemberExpression","messageId":"unboundWithoutThisAnnotation","endLine":180,"endColumn":38}

// Note: We can't directly verify the Blob's type in the mock,
// but the integration tests in idb.test.ts verify the full round-trip
})
})
6 changes: 4 additions & 2 deletions src/ui/load_samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,17 @@ export const loadSample = async (sample: SampleDetails) => {
const cached = await db.samples.where('url').equals(sample.url).first()
let blob: Blob
if (cached) {
blob = cached.blob
blob = new Blob([cached.arrayBuffer], { type: cached.mimeType })
} else {
// CAF files will be transparently converted by server middleware
const response = await fetch(sample.url)
blob = await response.blob()
const arrayBuffer = await blob.arrayBuffer()
await db.samples.put({
name: sample.name,
url: sample.url,
blob,
arrayBuffer,
mimeType: blob.type,
})
}
const blobUrl = URL.createObjectURL(blob)
Expand Down
Loading