Skip to content

Commit 92f956d

Browse files
R2 base64 compatibility removal (#684)
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 99ab2ba commit 92f956d

File tree

9 files changed

+157
-85
lines changed

9 files changed

+157
-85
lines changed

app/routes/resources/calls/call-audio.ts

Lines changed: 30 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { createReadableStreamFromReadable } from '@react-router/node'
33
import {
44
getAudioBuffer,
55
getAudioStream,
6+
headAudioObject,
67
parseHttpByteRangeHeader,
7-
parseBase64DataUrl,
88
} from '#app/utils/call-kent-audio-storage.server.ts'
99
import { prisma } from '#app/utils/prisma.server.ts'
1010
import { requireUser } from '#app/utils/session.server.ts'
@@ -23,25 +23,42 @@ export async function loader({ request }: Route.LoaderArgs) {
2323
audioKey: true,
2424
audioContentType: true,
2525
audioSize: true,
26-
base64: true, // legacy fallback
2726
},
2827
})
2928
if (!call) throw new Response('Not found', { status: 404 })
29+
if (!call.audioKey) throw new Response('Not found', { status: 404 })
3030

31+
const audioKey = call.audioKey
3132
let contentType = call.audioContentType ?? 'application/octet-stream'
3233
let size = call.audioSize ?? null
3334

3435
const rangeHeader = request.headers.get('range')
35-
if (call.audioKey) {
36-
// R2-backed
37-
if (typeof size !== 'number' || !Number.isFinite(size) || size <= 0) {
38-
// Size should always be present for R2-backed audio; keep a safe fallback.
39-
const buffer = await getAudioBuffer({ key: call.audioKey! })
36+
// R2-backed (or mock implementation when running locally with mocks).
37+
if (typeof size !== 'number' || !Number.isFinite(size) || size <= 0) {
38+
// Size should always be present for stored audio. Prefer HEAD metadata over
39+
// buffering the entire object.
40+
try {
41+
const head = await headAudioObject({ key: audioKey })
42+
size = head.size
43+
if (contentType === 'application/octet-stream' && head.contentType) {
44+
contentType = head.contentType
45+
}
46+
console.warn(
47+
`call-audio: missing/invalid audioSize for callId=${callId} audioKey=${audioKey} resolvedSize=${size} source=head`,
48+
)
49+
} catch {
50+
// Fall back to buffering only if HEAD fails for some reason.
51+
const buffer = await getAudioBuffer({ key: audioKey })
4052
size = buffer.byteLength
53+
console.warn(
54+
`call-audio: missing/invalid audioSize for callId=${callId} audioKey=${audioKey} resolvedSize=${size} source=buffer`,
55+
)
4156
const range = rangeHeader
4257
? parseHttpByteRangeHeader(rangeHeader, size)
4358
: null
44-
const body = range ? buffer.subarray(range.start, range.end + 1) : buffer
59+
const body = range
60+
? buffer.subarray(range.start, range.end + 1)
61+
: buffer
4562
const stream = Readable.from(body)
4663
return new Response(createReadableStreamFromReadable(stream), {
4764
status: range ? 206 : 200,
@@ -58,41 +75,14 @@ export async function loader({ request }: Route.LoaderArgs) {
5875
},
5976
})
6077
}
61-
62-
const range = rangeHeader
63-
? parseHttpByteRangeHeader(rangeHeader, size)
64-
: null
65-
const { body } = await getAudioStream({
66-
key: call.audioKey,
67-
range: range ?? undefined,
68-
})
69-
return new Response(createReadableStreamFromReadable(body), {
70-
status: range ? 206 : 200,
71-
headers: {
72-
'Content-Type': contentType,
73-
'Accept-Ranges': 'bytes',
74-
...(range
75-
? {
76-
'Content-Range': `bytes ${range.start}-${range.end}/${size}`,
77-
'Content-Length': String(range.end - range.start + 1),
78-
}
79-
: { 'Content-Length': String(size) }),
80-
'Cache-Control': 'private, max-age=3600',
81-
},
82-
})
8378
}
8479

85-
// Legacy DB-backed base64 audio
86-
if (!call.base64) throw new Response('Not found', { status: 404 })
87-
const parsed = parseBase64DataUrl(call.base64)
88-
contentType = parsed.contentType
89-
size = parsed.buffer.byteLength
9080
const range = rangeHeader ? parseHttpByteRangeHeader(rangeHeader, size) : null
91-
const body = range
92-
? parsed.buffer.subarray(range.start, range.end + 1)
93-
: parsed.buffer
94-
const stream = Readable.from(body)
95-
return new Response(createReadableStreamFromReadable(stream), {
81+
const { body } = await getAudioStream({
82+
key: audioKey,
83+
range: range ?? undefined,
84+
})
85+
return new Response(createReadableStreamFromReadable(body), {
9686
status: range ? 206 : 200,
9787
headers: {
9888
'Content-Type': contentType,

app/routes/resources/calls/save.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,6 @@ async function createCall({
105105
audioKey: stored.key,
106106
audioContentType: stored.contentType,
107107
audioSize: stored.size,
108-
base64: null,
109108
},
110109
select: { id: true },
111110
})

app/utils/__tests__/call-kent-audio-storage.server.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
deleteAudioObject,
44
getAudioBuffer,
55
getAudioStream,
6+
headAudioObject,
67
putCallAudioFromBuffer,
78
} from '../call-kent-audio-storage.server.ts'
89

@@ -43,4 +44,22 @@ describe('call kent audio storage (R2 via MSW)', () => {
4344
await deleteAudioObject({ key: putRes.key })
4445
await expect(getAudioBuffer({ key: putRes.key })).rejects.toThrow()
4546
})
47+
48+
test('supports HEAD lookups for object size', async () => {
49+
const callId = 'call_456'
50+
const original = new Uint8Array([1, 2, 3, 4, 5, 6, 7])
51+
const contentType = 'audio/webm;codecs=opus'
52+
53+
const putRes = await putCallAudioFromBuffer({
54+
callId,
55+
audio: original,
56+
contentType,
57+
})
58+
59+
const head = await headAudioObject({ key: putRes.key })
60+
expect(head.size).toBe(original.byteLength)
61+
expect(head.contentType).toBe(contentType)
62+
63+
await deleteAudioObject({ key: putRes.key })
64+
})
4665
})

app/utils/call-kent-audio-storage.server.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Readable } from 'node:stream'
22
import {
33
DeleteObjectCommand,
44
GetObjectCommand,
5+
HeadObjectCommand,
56
PutObjectCommand,
67
S3Client,
78
} from '@aws-sdk/client-s3'
@@ -17,6 +18,11 @@ type GetAudioStreamResult = {
1718
body: Readable
1819
}
1920

21+
type HeadAudioResult = {
22+
size: number
23+
contentType: string | null
24+
}
25+
2026
type AudioStore = {
2127
put: (args: {
2228
key: string
@@ -27,6 +33,7 @@ type AudioStore = {
2733
key: string
2834
range?: { start: number; end: number }
2935
}) => Promise<GetAudioStreamResult>
36+
head: (args: { key: string }) => Promise<HeadAudioResult>
3037
delete: (args: { key: string }) => Promise<void>
3138
}
3239

@@ -157,6 +164,23 @@ function createR2Store({ bucket }: { bucket: string }): AudioStore {
157164
}
158165
return { body }
159166
},
167+
async head({ key }) {
168+
const res = await client.send(
169+
new HeadObjectCommand({
170+
Bucket: bucket,
171+
Key: key,
172+
}),
173+
)
174+
const size = res.ContentLength
175+
if (typeof size !== 'number' || !Number.isFinite(size) || size <= 0) {
176+
throw new Error('Unexpected audio ContentLength')
177+
}
178+
const contentType =
179+
typeof res.ContentType === 'string' && res.ContentType.trim()
180+
? res.ContentType.trim()
181+
: null
182+
return { size, contentType }
183+
},
160184
async delete({ key }) {
161185
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }))
162186
},
@@ -231,6 +255,11 @@ export async function getAudioStream({
231255
return await store.getStream({ key, range })
232256
}
233257

258+
export async function headAudioObject({ key }: { key: string }) {
259+
const { store } = getStore()
260+
return await store.head({ key })
261+
}
262+
234263
export async function deleteAudioObject({ key }: { key: string }) {
235264
const { store } = getStore()
236265
await store.delete({ key })

app/utils/call-kent-episode-draft.server.ts

Lines changed: 5 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import {
22
getAudioBuffer,
33
parseBase64DataUrl,
4-
putCallAudioFromBuffer,
54
putEpisodeDraftAudioFromBuffer,
65
} from '#app/utils/call-kent-audio-storage.server.ts'
76
import { assembleCallKentTranscript } from '#app/utils/call-kent-transcript-template.ts'
@@ -27,7 +26,6 @@ export async function startCallKentEpisodeDraftProcessing(
2726
notes: true,
2827
isAnonymous: true,
2928
audioKey: true,
30-
base64: true, // legacy fallback (pre-R2)
3129
user: { select: { firstName: true } },
3230
},
3331
},
@@ -36,36 +34,12 @@ export async function startCallKentEpisodeDraftProcessing(
3634
if (!draft) return
3735
if (draft.status !== 'PROCESSING') return
3836

39-
// Caller audio (from R2 or legacy base64)
40-
let callAudio: Buffer | null = null
41-
if (draft.call.audioKey) {
42-
callAudio = await getAudioBuffer({ key: draft.call.audioKey })
43-
} else if (draft.call.base64) {
44-
const parsed = parseBase64DataUrl(draft.call.base64)
45-
callAudio = parsed.buffer
46-
// Best-effort migration of legacy DB-stored audio into R2/disk storage.
47-
try {
48-
const stored = await putCallAudioFromBuffer({
49-
callId: draft.call.id,
50-
audio: callAudio,
51-
contentType: parsed.contentType,
52-
})
53-
await prisma.call.updateMany({
54-
where: { id: draft.call.id, audioKey: null },
55-
data: {
56-
audioKey: stored.key,
57-
audioContentType: stored.contentType,
58-
audioSize: stored.size,
59-
base64: null,
60-
},
61-
})
62-
} catch {
63-
// Keep going; we can still generate the episode from the legacy audio.
64-
}
65-
}
66-
if (!callAudio) {
67-
throw new Error('Call audio is missing (no R2 key and no legacy base64).')
37+
// Caller audio (stored in R2/disk and referenced by `audioKey`).
38+
const callAudioKey = draft.call.audioKey
39+
if (!callAudioKey) {
40+
throw new Error('Call audio is missing (audioKey is null).')
6841
}
42+
const callAudio = await getAudioBuffer({ key: callAudioKey })
6943

7044
// Step 1: episode audio (stitch + persist) if needed
7145
let episodeMp3: Buffer

mocks/cloudflare-r2.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,42 @@ export const cloudflareR2Handlers: Array<HttpHandler> = [
299299
return new HttpResponse('', { status: 204 })
300300
}
301301

302+
// HeadObject: HEAD /:bucket/:key
303+
if (request.method === 'HEAD') {
304+
if (!key) {
305+
return new HttpResponse(
306+
errorXml({ code: 'NoSuchKey', message: 'Missing Key' }),
307+
{
308+
status: 404,
309+
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
310+
},
311+
)
312+
}
313+
const obj = store.get(key)
314+
if (!obj) {
315+
return new HttpResponse(
316+
errorXml({
317+
code: 'NoSuchKey',
318+
message: 'The specified key does not exist.',
319+
}),
320+
{
321+
status: 404,
322+
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
323+
},
324+
)
325+
}
326+
return new HttpResponse('', {
327+
status: 200,
328+
headers: {
329+
'Content-Type': obj.contentType,
330+
'Content-Length': String(obj.size),
331+
ETag: `"${obj.etag}"`,
332+
'Last-Modified': obj.lastModified,
333+
'Cache-Control': 'no-store',
334+
},
335+
})
336+
}
337+
302338
// GetObject: GET /:bucket/:key
303339
if (request.method === 'GET') {
304340
if (!key) {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
-- Preflight: refuse to drop legacy DB base64 audio unless backfill is complete.
2+
-- If any calls still have `base64` audio but no `audioKey`, dropping the column
3+
-- would permanently remove their audio.
4+
CREATE TEMP TABLE "__prisma_migrate_call_base64_backfill_guard" (
5+
ok INTEGER NOT NULL,
6+
CONSTRAINT "call_base64_backfill_required" CHECK (ok = 1)
7+
);
8+
INSERT INTO "__prisma_migrate_call_base64_backfill_guard" ("ok")
9+
SELECT 0
10+
WHERE EXISTS (
11+
SELECT 1 FROM "Call"
12+
WHERE "audioKey" IS NULL AND "base64" IS NOT NULL
13+
);
14+
DROP TABLE "__prisma_migrate_call_base64_backfill_guard";
15+
16+
-- RedefineTables
17+
PRAGMA defer_foreign_keys=ON;
18+
PRAGMA foreign_keys=OFF;
19+
CREATE TABLE "new_Call" (
20+
"id" TEXT NOT NULL PRIMARY KEY,
21+
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
22+
"updatedAt" DATETIME NOT NULL,
23+
"title" TEXT NOT NULL,
24+
"notes" TEXT,
25+
"isAnonymous" BOOLEAN NOT NULL DEFAULT false,
26+
"userId" TEXT NOT NULL,
27+
"audioKey" TEXT,
28+
"audioContentType" TEXT,
29+
"audioSize" INTEGER,
30+
CONSTRAINT "Call_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
31+
);
32+
INSERT INTO "new_Call" ("audioContentType", "audioKey", "audioSize", "createdAt", "id", "isAnonymous", "notes", "title", "updatedAt", "userId") SELECT "audioContentType", "audioKey", "audioSize", "createdAt", "id", "isAnonymous", "notes", "title", "updatedAt", "userId" FROM "Call";
33+
DROP TABLE "Call";
34+
ALTER TABLE "new_Call" RENAME TO "Call";
35+
CREATE INDEX "Call_createdAt_idx" ON "Call"("createdAt");
36+
PRAGMA foreign_keys=ON;
37+
PRAGMA defer_foreign_keys=OFF;

prisma/schema.prisma

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,10 @@ model Call {
8080
isAnonymous Boolean @default(false)
8181
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
8282
userId String
83-
// Preferred: store audio in R2 and keep a small DB row.
83+
// Caller audio is stored in R2; the DB stores metadata + key.
8484
audioKey String?
8585
audioContentType String?
8686
audioSize Int?
87-
// Legacy fallback: base64 data URL stored in the DB (pre-R2 migration).
88-
base64 String?
8987
episodeDraft CallKentEpisodeDraft?
9088
9189
@@index([createdAt])

prisma/seed.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,6 @@ async function main() {
6565
},
6666
})
6767

68-
await prisma.call.create({
69-
data: {
70-
title: 'Thoughts on the whole "Bears" thing for Koalas',
71-
notes: `I'm a Koala. I'm not a bear. What do you think about that?`,
72-
base64:
73-
'data:audio/mp3;base64,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',
74-
userId: kody.id,
75-
},
76-
})
77-
7868
// we'll stick one really old read in for peter to make sure he doesn't
7969
// show up in the rankings as an active user, but the read *should* show up in the total reads.
8070
const postReads = [

0 commit comments

Comments
 (0)