@opengeni/db 0.27.11 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1508 @@
1
+ import type {
2
+ TranscriptionRecording,
3
+ TranscriptionRecordingErrorCode,
4
+ TranscriptionRecordingResponse,
5
+ TranscriptionRecordingSegment,
6
+ } from "@opengeni/contracts";
7
+ import { and, asc, desc, eq, gt, inArray, isNull, ne, or, sql } from "drizzle-orm";
8
+ import type { Database } from "./database";
9
+ import { withWorkspaceSubjectRls } from "./database";
10
+ import * as schema from "./schema";
11
+
12
+ type RecordingRow = typeof schema.transcriptionRecordings.$inferSelect;
13
+ type ChunkRow = typeof schema.transcriptionRecordingChunks.$inferSelect;
14
+ type SegmentRow = typeof schema.transcriptionRecordingSegments.$inferSelect;
15
+
16
+ export class TranscriptionRecordingNotFoundError extends Error {
17
+ readonly name = "TranscriptionRecordingNotFoundError";
18
+ }
19
+
20
+ export class TranscriptionRecordingConflictError extends Error {
21
+ readonly name = "TranscriptionRecordingConflictError";
22
+ }
23
+
24
+ export class TranscriptionRecordingStateError extends Error {
25
+ readonly name = "TranscriptionRecordingStateError";
26
+ }
27
+
28
+ export type TranscriptionRecordingChunkReservation = {
29
+ recording: TranscriptionRecordingResponse;
30
+ chunk: ChunkRow;
31
+ deduplicated: boolean;
32
+ };
33
+
34
+ export type TranscriptionRecordingAssemblyClaim = {
35
+ recording: TranscriptionRecordingResponse;
36
+ claimed: boolean;
37
+ generation: number;
38
+ owner: string | null;
39
+ staleObjectKeys: string[];
40
+ };
41
+
42
+ export type TranscriptionRecordingSegmentClaim = {
43
+ recording: TranscriptionRecordingResponse;
44
+ claimed: boolean;
45
+ attemptId: string | null;
46
+ segment: SegmentRow | null;
47
+ };
48
+
49
+ export type TranscriptionRecordingObjectCleanupClaim = {
50
+ accountId: string;
51
+ workspaceId: string;
52
+ subjectId: string;
53
+ recordingId: string;
54
+ objectKey: string;
55
+ cleanupClaimId: string;
56
+ };
57
+
58
+ function iso(value: Date | string): string {
59
+ return (value instanceof Date ? value : new Date(value)).toISOString();
60
+ }
61
+
62
+ function languages(value: unknown): string[] {
63
+ if (!Array.isArray(value)) return [];
64
+ return value.filter((entry): entry is string => typeof entry === "string");
65
+ }
66
+
67
+ /**
68
+ * A stale attempt is reclaimable only after both durable fences have expired:
69
+ * the processing lease and the server-owned provider deadline. Missing
70
+ * deadlines fail closed so an old pre-deadline row cannot create an overlap.
71
+ */
72
+ export function canReclaimTranscriptionRecordingAttempt(input: {
73
+ attemptStartedAt: Date | null;
74
+ attemptDeadlineAt: Date | null;
75
+ staleBefore: Date;
76
+ now: Date;
77
+ }): boolean {
78
+ return Boolean(
79
+ input.attemptStartedAt &&
80
+ input.attemptDeadlineAt &&
81
+ input.attemptStartedAt < input.staleBefore &&
82
+ input.attemptDeadlineAt <= input.now,
83
+ );
84
+ }
85
+
86
+ function segmentFromRow(row: SegmentRow): TranscriptionRecordingSegment {
87
+ return {
88
+ segmentNumber: row.segmentNumber,
89
+ state: row.state,
90
+ startMilliseconds: row.startMilliseconds,
91
+ durationMilliseconds: row.durationMilliseconds,
92
+ byteLength: row.byteLength,
93
+ errorCode: (row.errorCode as TranscriptionRecordingErrorCode | null) ?? null,
94
+ retryable: row.retryable,
95
+ };
96
+ }
97
+
98
+ function recordingFromRow(row: RecordingRow): TranscriptionRecording {
99
+ return {
100
+ id: row.id,
101
+ workspaceId: row.workspaceId,
102
+ mimeType: row.mimeType,
103
+ state: row.state,
104
+ nextChunkNumber: row.nextChunkNumber,
105
+ chunkCount: row.chunkCount,
106
+ totalBytes: row.totalBytes,
107
+ totalDurationMilliseconds: row.totalDurationMilliseconds,
108
+ segmentCount: row.segmentCount,
109
+ completedSegmentCount: row.completedSegmentCount,
110
+ transcriptText: row.transcriptText,
111
+ languages: languages(row.languages),
112
+ errorCode: (row.errorCode as TranscriptionRecordingErrorCode | null) ?? null,
113
+ retryable: row.retryable,
114
+ objectsCleaned: row.objectsCleanedAt !== null,
115
+ createdAt: iso(row.createdAt),
116
+ updatedAt: iso(row.updatedAt),
117
+ expiresAt: iso(row.expiresAt),
118
+ };
119
+ }
120
+
121
+ async function recordingRow(
122
+ scopedDb: Database,
123
+ workspaceId: string,
124
+ recordingId: string,
125
+ lock = false,
126
+ ): Promise<RecordingRow | null> {
127
+ let query: any = scopedDb
128
+ .select()
129
+ .from(schema.transcriptionRecordings)
130
+ .where(
131
+ and(
132
+ eq(schema.transcriptionRecordings.workspaceId, workspaceId),
133
+ eq(schema.transcriptionRecordings.id, recordingId),
134
+ ),
135
+ )
136
+ .limit(1);
137
+ if (lock) query = query.for("update");
138
+ const [row] = await query;
139
+ return row ?? null;
140
+ }
141
+
142
+ async function detailForRow(
143
+ scopedDb: Database,
144
+ row: RecordingRow,
145
+ ): Promise<TranscriptionRecordingResponse> {
146
+ const segmentRows = await scopedDb
147
+ .select()
148
+ .from(schema.transcriptionRecordingSegments)
149
+ .where(
150
+ and(
151
+ eq(schema.transcriptionRecordingSegments.workspaceId, row.workspaceId),
152
+ eq(schema.transcriptionRecordingSegments.recordingId, row.id),
153
+ ),
154
+ )
155
+ .orderBy(asc(schema.transcriptionRecordingSegments.segmentNumber))
156
+ .limit(1_001);
157
+ if (segmentRows.length > 1_000) {
158
+ throw new Error("Transcription recording exceeded the segment projection limit");
159
+ }
160
+ return {
161
+ recording: recordingFromRow(row),
162
+ segments: segmentRows.map(segmentFromRow),
163
+ };
164
+ }
165
+
166
+ async function requiredRecordingRow(
167
+ scopedDb: Database,
168
+ workspaceId: string,
169
+ recordingId: string,
170
+ lock = false,
171
+ ): Promise<RecordingRow> {
172
+ const row = await recordingRow(scopedDb, workspaceId, recordingId, lock);
173
+ if (!row) throw new TranscriptionRecordingNotFoundError("Recording not found");
174
+ return row;
175
+ }
176
+
177
+ export function transcriptionRecordingChunkObjectKey(input: {
178
+ accountId: string;
179
+ workspaceId: string;
180
+ recordingId: string;
181
+ chunkNumber: number;
182
+ sha256: string;
183
+ }): string {
184
+ return [
185
+ "transcription-recordings",
186
+ input.accountId,
187
+ input.workspaceId,
188
+ input.recordingId,
189
+ "chunks",
190
+ `${input.chunkNumber.toString().padStart(8, "0")}-${input.sha256}.bin`,
191
+ ].join("/");
192
+ }
193
+
194
+ export function transcriptionRecordingSegmentObjectKey(input: {
195
+ accountId: string;
196
+ workspaceId: string;
197
+ recordingId: string;
198
+ generation: number;
199
+ segmentNumber: number;
200
+ sha256: string;
201
+ }): string {
202
+ return [
203
+ "transcription-recordings",
204
+ input.accountId,
205
+ input.workspaceId,
206
+ input.recordingId,
207
+ "segments",
208
+ input.generation.toString().padStart(8, "0"),
209
+ `${input.segmentNumber.toString().padStart(8, "0")}-${input.sha256}.wav`,
210
+ ].join("/");
211
+ }
212
+
213
+ export async function createTranscriptionRecording(
214
+ db: Database,
215
+ input: {
216
+ accountId: string;
217
+ workspaceId: string;
218
+ subjectId: string;
219
+ recordingId: string;
220
+ mimeType: string;
221
+ expiresAt: Date;
222
+ },
223
+ ): Promise<TranscriptionRecordingResponse> {
224
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
225
+ await scopedDb
226
+ .insert(schema.transcriptionRecordings)
227
+ .values({
228
+ id: input.recordingId,
229
+ accountId: input.accountId,
230
+ workspaceId: input.workspaceId,
231
+ subjectId: input.subjectId,
232
+ mimeType: input.mimeType,
233
+ expiresAt: input.expiresAt,
234
+ })
235
+ .onConflictDoNothing();
236
+ const row = await recordingRow(scopedDb, input.workspaceId, input.recordingId);
237
+ if (!row) {
238
+ throw new TranscriptionRecordingConflictError("Recording id is already in use");
239
+ }
240
+ if (row.mimeType !== input.mimeType) {
241
+ throw new TranscriptionRecordingConflictError(
242
+ "Recording id is already bound to different metadata",
243
+ );
244
+ }
245
+ return await detailForRow(scopedDb, row);
246
+ });
247
+ }
248
+
249
+ export async function getTranscriptionRecording(
250
+ db: Database,
251
+ input: { workspaceId: string; subjectId: string; recordingId: string },
252
+ ): Promise<TranscriptionRecordingResponse> {
253
+ return await withWorkspaceSubjectRls(
254
+ db,
255
+ input.workspaceId,
256
+ input.subjectId,
257
+ async (scopedDb) =>
258
+ await detailForRow(
259
+ scopedDb,
260
+ await requiredRecordingRow(scopedDb, input.workspaceId, input.recordingId),
261
+ ),
262
+ );
263
+ }
264
+
265
+ export async function listTranscriptionRecordings(
266
+ db: Database,
267
+ input: { workspaceId: string; subjectId: string; limit?: number },
268
+ ): Promise<TranscriptionRecording[]> {
269
+ const limit = Math.min(Math.max(input.limit ?? 20, 1), 50);
270
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
271
+ const rows = await scopedDb
272
+ .select()
273
+ .from(schema.transcriptionRecordings)
274
+ .where(
275
+ and(
276
+ eq(schema.transcriptionRecordings.workspaceId, input.workspaceId),
277
+ ne(schema.transcriptionRecordings.state, "discarded"),
278
+ gt(schema.transcriptionRecordings.expiresAt, new Date()),
279
+ ),
280
+ )
281
+ .orderBy(
282
+ desc(schema.transcriptionRecordings.createdAt),
283
+ desc(schema.transcriptionRecordings.id),
284
+ )
285
+ .limit(limit);
286
+ return rows.map(recordingFromRow);
287
+ });
288
+ }
289
+
290
+ function sameChunk(
291
+ row: ChunkRow,
292
+ input: {
293
+ byteLength: number;
294
+ sha256: string;
295
+ startMilliseconds: number;
296
+ durationMilliseconds: number;
297
+ },
298
+ ): boolean {
299
+ return (
300
+ row.byteLength === input.byteLength &&
301
+ row.sha256 === input.sha256 &&
302
+ row.startMilliseconds === input.startMilliseconds &&
303
+ row.durationMilliseconds === input.durationMilliseconds
304
+ );
305
+ }
306
+
307
+ export async function reserveTranscriptionRecordingChunk(
308
+ db: Database,
309
+ input: {
310
+ accountId: string;
311
+ workspaceId: string;
312
+ subjectId: string;
313
+ recordingId: string;
314
+ chunkNumber: number;
315
+ byteLength: number;
316
+ sha256: string;
317
+ startMilliseconds: number;
318
+ durationMilliseconds: number;
319
+ maxTotalBytes: number;
320
+ maxDurationMilliseconds: number;
321
+ },
322
+ ): Promise<TranscriptionRecordingChunkReservation> {
323
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
324
+ const recording = await requiredRecordingRow(
325
+ scopedDb,
326
+ input.workspaceId,
327
+ input.recordingId,
328
+ true,
329
+ );
330
+ if (recording.state !== "uploading") {
331
+ throw new TranscriptionRecordingStateError("Recording is no longer accepting chunks");
332
+ }
333
+ if (recording.expiresAt.getTime() <= Date.now()) {
334
+ throw new TranscriptionRecordingStateError("Recording has expired");
335
+ }
336
+ const [existing] = await scopedDb
337
+ .select()
338
+ .from(schema.transcriptionRecordingChunks)
339
+ .where(
340
+ and(
341
+ eq(schema.transcriptionRecordingChunks.recordingId, input.recordingId),
342
+ eq(schema.transcriptionRecordingChunks.chunkNumber, input.chunkNumber),
343
+ ),
344
+ )
345
+ .limit(1)
346
+ .for("update");
347
+ if (existing) {
348
+ if (!sameChunk(existing, input)) {
349
+ throw new TranscriptionRecordingConflictError("Chunk metadata or hash conflicts");
350
+ }
351
+ return {
352
+ recording: await detailForRow(scopedDb, recording),
353
+ chunk: existing,
354
+ deduplicated: existing.state === "complete",
355
+ };
356
+ }
357
+ if (input.chunkNumber !== recording.nextChunkNumber) {
358
+ throw new TranscriptionRecordingConflictError(
359
+ `Expected chunk ${recording.nextChunkNumber}, received ${input.chunkNumber}`,
360
+ );
361
+ }
362
+ if (input.startMilliseconds !== recording.totalDurationMilliseconds) {
363
+ throw new TranscriptionRecordingConflictError(
364
+ `Expected chunk start ${recording.totalDurationMilliseconds}, received ${input.startMilliseconds}`,
365
+ );
366
+ }
367
+ if (recording.totalBytes + input.byteLength > input.maxTotalBytes) {
368
+ throw new TranscriptionRecordingStateError("Recording exceeds the byte limit");
369
+ }
370
+ if (
371
+ recording.totalDurationMilliseconds + input.durationMilliseconds >
372
+ input.maxDurationMilliseconds
373
+ ) {
374
+ throw new TranscriptionRecordingStateError("Recording exceeds the duration limit");
375
+ }
376
+ const objectKey = transcriptionRecordingChunkObjectKey(input);
377
+ await scopedDb.insert(schema.transcriptionRecordingObjects).values({
378
+ accountId: input.accountId,
379
+ workspaceId: input.workspaceId,
380
+ subjectId: input.subjectId,
381
+ recordingId: input.recordingId,
382
+ objectKey,
383
+ kind: "chunk",
384
+ cleanupAfter: recording.expiresAt,
385
+ });
386
+ const [chunk] = await scopedDb
387
+ .insert(schema.transcriptionRecordingChunks)
388
+ .values({
389
+ accountId: input.accountId,
390
+ workspaceId: input.workspaceId,
391
+ subjectId: input.subjectId,
392
+ recordingId: input.recordingId,
393
+ chunkNumber: input.chunkNumber,
394
+ byteLength: input.byteLength,
395
+ sha256: input.sha256,
396
+ startMilliseconds: input.startMilliseconds,
397
+ durationMilliseconds: input.durationMilliseconds,
398
+ objectKey,
399
+ })
400
+ .returning();
401
+ if (!chunk) throw new Error("Chunk reservation did not return a row");
402
+ return {
403
+ recording: await detailForRow(scopedDb, recording),
404
+ chunk,
405
+ deduplicated: false,
406
+ };
407
+ });
408
+ }
409
+
410
+ export async function completeTranscriptionRecordingChunk(
411
+ db: Database,
412
+ input: { workspaceId: string; subjectId: string; recordingId: string; chunkNumber: number },
413
+ ): Promise<TranscriptionRecordingChunkReservation> {
414
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
415
+ let recording = await requiredRecordingRow(
416
+ scopedDb,
417
+ input.workspaceId,
418
+ input.recordingId,
419
+ true,
420
+ );
421
+ const [chunk] = await scopedDb
422
+ .select()
423
+ .from(schema.transcriptionRecordingChunks)
424
+ .where(
425
+ and(
426
+ eq(schema.transcriptionRecordingChunks.recordingId, input.recordingId),
427
+ eq(schema.transcriptionRecordingChunks.chunkNumber, input.chunkNumber),
428
+ ),
429
+ )
430
+ .limit(1)
431
+ .for("update");
432
+ if (!chunk) throw new TranscriptionRecordingNotFoundError("Chunk not found");
433
+ if (chunk.state === "complete") {
434
+ return {
435
+ recording: await detailForRow(scopedDb, recording),
436
+ chunk,
437
+ deduplicated: true,
438
+ };
439
+ }
440
+ if (recording.state !== "uploading" || recording.nextChunkNumber !== input.chunkNumber) {
441
+ throw new TranscriptionRecordingStateError("Chunk completion is no longer current");
442
+ }
443
+ const now = new Date();
444
+ const [completedChunk] = await scopedDb
445
+ .update(schema.transcriptionRecordingChunks)
446
+ .set({ state: "complete", completedAt: now })
447
+ .where(
448
+ and(
449
+ eq(schema.transcriptionRecordingChunks.recordingId, input.recordingId),
450
+ eq(schema.transcriptionRecordingChunks.chunkNumber, input.chunkNumber),
451
+ eq(schema.transcriptionRecordingChunks.state, "uploading"),
452
+ ),
453
+ )
454
+ .returning();
455
+ if (!completedChunk) throw new TranscriptionRecordingStateError("Chunk completion was lost");
456
+ const [updated] = await scopedDb
457
+ .update(schema.transcriptionRecordings)
458
+ .set({
459
+ nextChunkNumber: input.chunkNumber + 1,
460
+ chunkCount: recording.chunkCount + 1,
461
+ totalBytes: recording.totalBytes + chunk.byteLength,
462
+ totalDurationMilliseconds: recording.totalDurationMilliseconds + chunk.durationMilliseconds,
463
+ updatedAt: now,
464
+ })
465
+ .where(
466
+ and(
467
+ eq(schema.transcriptionRecordings.id, input.recordingId),
468
+ eq(schema.transcriptionRecordings.nextChunkNumber, input.chunkNumber),
469
+ eq(schema.transcriptionRecordings.state, "uploading"),
470
+ ),
471
+ )
472
+ .returning();
473
+ if (!updated) throw new TranscriptionRecordingStateError("Recording chunk fence was lost");
474
+ recording = updated;
475
+ return {
476
+ recording: await detailForRow(scopedDb, recording),
477
+ chunk: completedChunk,
478
+ deduplicated: false,
479
+ };
480
+ });
481
+ }
482
+
483
+ export async function claimTranscriptionRecordingAssembly(
484
+ db: Database,
485
+ input: {
486
+ workspaceId: string;
487
+ subjectId: string;
488
+ recordingId: string;
489
+ owner: string;
490
+ chunkCount: number;
491
+ totalBytes: number;
492
+ totalDurationMilliseconds: number;
493
+ staleBefore: Date;
494
+ },
495
+ ): Promise<TranscriptionRecordingAssemblyClaim> {
496
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
497
+ let recording = await requiredRecordingRow(
498
+ scopedDb,
499
+ input.workspaceId,
500
+ input.recordingId,
501
+ true,
502
+ );
503
+ if (recording.state === "discarded") {
504
+ throw new TranscriptionRecordingStateError("Recording was discarded");
505
+ }
506
+ if (recording.expiresAt.getTime() <= Date.now()) {
507
+ throw new TranscriptionRecordingStateError("Recording has expired");
508
+ }
509
+ if (recording.segmentCount > 0 || recording.state === "complete") {
510
+ return {
511
+ recording: await detailForRow(scopedDb, recording),
512
+ claimed: false,
513
+ generation: recording.processingGeneration,
514
+ owner: recording.processingOwner,
515
+ staleObjectKeys: [],
516
+ };
517
+ }
518
+ if (
519
+ recording.state === "segmenting" &&
520
+ recording.processingStartedAt &&
521
+ recording.processingStartedAt >= input.staleBefore
522
+ ) {
523
+ return {
524
+ recording: await detailForRow(scopedDb, recording),
525
+ claimed: false,
526
+ generation: recording.processingGeneration,
527
+ owner: recording.processingOwner,
528
+ staleObjectKeys: [],
529
+ };
530
+ }
531
+ if (recording.state === "failed" && !recording.retryable) {
532
+ return {
533
+ recording: await detailForRow(scopedDb, recording),
534
+ claimed: false,
535
+ generation: recording.processingGeneration,
536
+ owner: null,
537
+ staleObjectKeys: [],
538
+ };
539
+ }
540
+ if (
541
+ recording.chunkCount !== input.chunkCount ||
542
+ recording.nextChunkNumber !== input.chunkCount ||
543
+ recording.totalBytes !== input.totalBytes ||
544
+ recording.totalDurationMilliseconds !== input.totalDurationMilliseconds
545
+ ) {
546
+ throw new TranscriptionRecordingConflictError(
547
+ "Finalization totals do not match upload truth",
548
+ );
549
+ }
550
+ const chunks = await scopedDb
551
+ .select({
552
+ chunkNumber: schema.transcriptionRecordingChunks.chunkNumber,
553
+ state: schema.transcriptionRecordingChunks.state,
554
+ })
555
+ .from(schema.transcriptionRecordingChunks)
556
+ .where(eq(schema.transcriptionRecordingChunks.recordingId, input.recordingId))
557
+ .orderBy(asc(schema.transcriptionRecordingChunks.chunkNumber));
558
+ if (
559
+ chunks.length !== input.chunkCount ||
560
+ chunks.some((chunk, index) => chunk.chunkNumber !== index || chunk.state !== "complete")
561
+ ) {
562
+ throw new TranscriptionRecordingConflictError("Recording chunks are incomplete");
563
+ }
564
+ const staleSegments = await scopedDb
565
+ .delete(schema.transcriptionRecordingSegments)
566
+ .where(eq(schema.transcriptionRecordingSegments.recordingId, input.recordingId))
567
+ .returning({ objectKey: schema.transcriptionRecordingSegments.objectKey });
568
+ if (staleSegments.length > 0) {
569
+ await scopedDb
570
+ .update(schema.transcriptionRecordingObjects)
571
+ .set({ cleanupAfter: new Date() })
572
+ .where(
573
+ inArray(
574
+ schema.transcriptionRecordingObjects.objectKey,
575
+ staleSegments.map((entry) => entry.objectKey),
576
+ ),
577
+ );
578
+ }
579
+ const generation = recording.processingGeneration + 1;
580
+ const now = new Date();
581
+ const [updated] = await scopedDb
582
+ .update(schema.transcriptionRecordings)
583
+ .set({
584
+ state: "segmenting",
585
+ processingGeneration: generation,
586
+ processingOwner: input.owner,
587
+ processingStartedAt: now,
588
+ segmentCount: 0,
589
+ completedSegmentCount: 0,
590
+ transcriptText: null,
591
+ languages: [],
592
+ errorCode: null,
593
+ retryable: false,
594
+ objectsCleanedAt: null,
595
+ updatedAt: now,
596
+ })
597
+ .where(eq(schema.transcriptionRecordings.id, input.recordingId))
598
+ .returning();
599
+ if (!updated) throw new TranscriptionRecordingStateError("Assembly claim was lost");
600
+ recording = updated;
601
+ return {
602
+ recording: await detailForRow(scopedDb, recording),
603
+ claimed: true,
604
+ generation,
605
+ owner: input.owner,
606
+ staleObjectKeys: staleSegments.map((entry) => entry.objectKey),
607
+ };
608
+ });
609
+ }
610
+
611
+ export async function listTranscriptionRecordingChunks(
612
+ db: Database,
613
+ input: { workspaceId: string; subjectId: string; recordingId: string },
614
+ ): Promise<ChunkRow[]> {
615
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
616
+ await requiredRecordingRow(scopedDb, input.workspaceId, input.recordingId);
617
+ return await scopedDb
618
+ .select()
619
+ .from(schema.transcriptionRecordingChunks)
620
+ .where(eq(schema.transcriptionRecordingChunks.recordingId, input.recordingId))
621
+ .orderBy(asc(schema.transcriptionRecordingChunks.chunkNumber));
622
+ });
623
+ }
624
+
625
+ export async function reserveTranscriptionRecordingSegment(
626
+ db: Database,
627
+ input: {
628
+ accountId: string;
629
+ workspaceId: string;
630
+ subjectId: string;
631
+ recordingId: string;
632
+ owner: string;
633
+ generation: number;
634
+ segmentNumber: number;
635
+ byteLength: number;
636
+ sha256: string;
637
+ startMilliseconds: number;
638
+ durationMilliseconds: number;
639
+ },
640
+ ): Promise<SegmentRow> {
641
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
642
+ const recording = await requiredRecordingRow(
643
+ scopedDb,
644
+ input.workspaceId,
645
+ input.recordingId,
646
+ true,
647
+ );
648
+ if (
649
+ recording.state !== "segmenting" ||
650
+ recording.processingGeneration !== input.generation ||
651
+ recording.processingOwner !== input.owner
652
+ ) {
653
+ throw new TranscriptionRecordingStateError("Assembly generation is no longer current");
654
+ }
655
+ const rows = await scopedDb
656
+ .select({ segmentNumber: schema.transcriptionRecordingSegments.segmentNumber })
657
+ .from(schema.transcriptionRecordingSegments)
658
+ .where(eq(schema.transcriptionRecordingSegments.recordingId, input.recordingId))
659
+ .orderBy(asc(schema.transcriptionRecordingSegments.segmentNumber));
660
+ if (
661
+ rows.length !== input.segmentNumber ||
662
+ rows.some((row, index) => row.segmentNumber !== index)
663
+ ) {
664
+ throw new TranscriptionRecordingConflictError("Segment sequence is not contiguous");
665
+ }
666
+ const objectKey = transcriptionRecordingSegmentObjectKey(input);
667
+ await scopedDb.insert(schema.transcriptionRecordingObjects).values({
668
+ accountId: input.accountId,
669
+ workspaceId: input.workspaceId,
670
+ subjectId: input.subjectId,
671
+ recordingId: input.recordingId,
672
+ objectKey,
673
+ kind: "segment",
674
+ cleanupAfter: recording.expiresAt,
675
+ });
676
+ const [segment] = await scopedDb
677
+ .insert(schema.transcriptionRecordingSegments)
678
+ .values({
679
+ accountId: input.accountId,
680
+ workspaceId: input.workspaceId,
681
+ subjectId: input.subjectId,
682
+ recordingId: input.recordingId,
683
+ segmentNumber: input.segmentNumber,
684
+ generation: input.generation,
685
+ byteLength: input.byteLength,
686
+ sha256: input.sha256,
687
+ startMilliseconds: input.startMilliseconds,
688
+ durationMilliseconds: input.durationMilliseconds,
689
+ objectKey,
690
+ })
691
+ .returning();
692
+ if (!segment) throw new Error("Segment reservation did not return a row");
693
+ return segment;
694
+ });
695
+ }
696
+
697
+ export async function completeTranscriptionRecordingSegmentPreparation(
698
+ db: Database,
699
+ input: {
700
+ workspaceId: string;
701
+ subjectId: string;
702
+ recordingId: string;
703
+ owner: string;
704
+ generation: number;
705
+ segmentNumber: number;
706
+ },
707
+ ): Promise<void> {
708
+ await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
709
+ const recording = await requiredRecordingRow(
710
+ scopedDb,
711
+ input.workspaceId,
712
+ input.recordingId,
713
+ true,
714
+ );
715
+ if (
716
+ recording.state !== "segmenting" ||
717
+ recording.processingGeneration !== input.generation ||
718
+ recording.processingOwner !== input.owner
719
+ ) {
720
+ throw new TranscriptionRecordingStateError("Assembly generation is no longer current");
721
+ }
722
+ const [updated] = await scopedDb
723
+ .update(schema.transcriptionRecordingSegments)
724
+ .set({ state: "pending", updatedAt: new Date() })
725
+ .where(
726
+ and(
727
+ eq(schema.transcriptionRecordingSegments.recordingId, input.recordingId),
728
+ eq(schema.transcriptionRecordingSegments.segmentNumber, input.segmentNumber),
729
+ eq(schema.transcriptionRecordingSegments.generation, input.generation),
730
+ eq(schema.transcriptionRecordingSegments.state, "preparing"),
731
+ ),
732
+ )
733
+ .returning({ segmentNumber: schema.transcriptionRecordingSegments.segmentNumber });
734
+ if (!updated) throw new TranscriptionRecordingStateError("Segment preparation was lost");
735
+ });
736
+ }
737
+
738
+ export async function completeTranscriptionRecordingAssembly(
739
+ db: Database,
740
+ input: {
741
+ workspaceId: string;
742
+ subjectId: string;
743
+ recordingId: string;
744
+ owner: string;
745
+ generation: number;
746
+ },
747
+ ): Promise<TranscriptionRecordingResponse> {
748
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
749
+ const recording = await requiredRecordingRow(
750
+ scopedDb,
751
+ input.workspaceId,
752
+ input.recordingId,
753
+ true,
754
+ );
755
+ if (
756
+ recording.state !== "segmenting" ||
757
+ recording.processingGeneration !== input.generation ||
758
+ recording.processingOwner !== input.owner
759
+ ) {
760
+ throw new TranscriptionRecordingStateError("Assembly generation is no longer current");
761
+ }
762
+ const segments = await scopedDb
763
+ .select()
764
+ .from(schema.transcriptionRecordingSegments)
765
+ .where(eq(schema.transcriptionRecordingSegments.recordingId, input.recordingId))
766
+ .orderBy(asc(schema.transcriptionRecordingSegments.segmentNumber));
767
+ if (
768
+ segments.length === 0 ||
769
+ segments.some(
770
+ (segment, index) =>
771
+ segment.segmentNumber !== index ||
772
+ segment.generation !== input.generation ||
773
+ segment.state !== "pending",
774
+ )
775
+ ) {
776
+ throw new TranscriptionRecordingStateError("Prepared segments are incomplete");
777
+ }
778
+ const [updated] = await scopedDb
779
+ .update(schema.transcriptionRecordings)
780
+ .set({
781
+ state: "ready",
782
+ segmentCount: segments.length,
783
+ completedSegmentCount: 0,
784
+ processingOwner: null,
785
+ processingStartedAt: null,
786
+ errorCode: null,
787
+ retryable: false,
788
+ updatedAt: new Date(),
789
+ })
790
+ .where(eq(schema.transcriptionRecordings.id, input.recordingId))
791
+ .returning();
792
+ if (!updated) throw new TranscriptionRecordingStateError("Assembly completion was lost");
793
+ return await detailForRow(scopedDb, updated);
794
+ });
795
+ }
796
+
797
+ export async function failTranscriptionRecordingAssembly(
798
+ db: Database,
799
+ input: {
800
+ workspaceId: string;
801
+ subjectId: string;
802
+ recordingId: string;
803
+ owner: string;
804
+ generation: number;
805
+ errorCode: TranscriptionRecordingErrorCode;
806
+ retryable: boolean;
807
+ },
808
+ ): Promise<TranscriptionRecordingResponse> {
809
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
810
+ const [updated] = await scopedDb
811
+ .update(schema.transcriptionRecordings)
812
+ .set({
813
+ state: "failed",
814
+ errorCode: input.errorCode,
815
+ retryable: input.retryable,
816
+ processingOwner: null,
817
+ processingStartedAt: null,
818
+ updatedAt: new Date(),
819
+ })
820
+ .where(
821
+ and(
822
+ eq(schema.transcriptionRecordings.id, input.recordingId),
823
+ eq(schema.transcriptionRecordings.workspaceId, input.workspaceId),
824
+ eq(schema.transcriptionRecordings.state, "segmenting"),
825
+ eq(schema.transcriptionRecordings.processingGeneration, input.generation),
826
+ eq(schema.transcriptionRecordings.processingOwner, input.owner),
827
+ ),
828
+ )
829
+ .returning();
830
+ if (!updated) throw new TranscriptionRecordingStateError("Assembly failure was stale");
831
+ if (!input.retryable) {
832
+ await scopedDb
833
+ .update(schema.transcriptionRecordingObjects)
834
+ .set({ cleanupAfter: new Date() })
835
+ .where(eq(schema.transcriptionRecordingObjects.recordingId, input.recordingId));
836
+ }
837
+ return await detailForRow(scopedDb, updated);
838
+ });
839
+ }
840
+
841
+ export async function claimNextTranscriptionRecordingSegment(
842
+ db: Database,
843
+ input: {
844
+ workspaceId: string;
845
+ subjectId: string;
846
+ recordingId: string;
847
+ attemptId: string;
848
+ providerId: string;
849
+ staleBefore: Date;
850
+ providerDeadlineAt: Date;
851
+ },
852
+ ): Promise<TranscriptionRecordingSegmentClaim> {
853
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
854
+ let recording = await requiredRecordingRow(
855
+ scopedDb,
856
+ input.workspaceId,
857
+ input.recordingId,
858
+ true,
859
+ );
860
+ if (recording.state === "complete" || recording.state === "discarded") {
861
+ return {
862
+ recording: await detailForRow(scopedDb, recording),
863
+ claimed: false,
864
+ attemptId: null,
865
+ segment: null,
866
+ };
867
+ }
868
+ if (recording.segmentCount === 0 || recording.state === "uploading") {
869
+ throw new TranscriptionRecordingStateError("Recording has not been finalized");
870
+ }
871
+ if (recording.expiresAt.getTime() <= Date.now()) {
872
+ throw new TranscriptionRecordingStateError("Recording has expired");
873
+ }
874
+ const [active] = await scopedDb
875
+ .select()
876
+ .from(schema.transcriptionRecordingSegments)
877
+ .where(
878
+ and(
879
+ eq(schema.transcriptionRecordingSegments.recordingId, input.recordingId),
880
+ eq(schema.transcriptionRecordingSegments.state, "transcribing"),
881
+ ),
882
+ )
883
+ .orderBy(asc(schema.transcriptionRecordingSegments.segmentNumber))
884
+ .limit(1)
885
+ .for("update");
886
+ if (
887
+ active &&
888
+ !canReclaimTranscriptionRecordingAttempt({
889
+ attemptStartedAt: active.attemptStartedAt,
890
+ attemptDeadlineAt: active.attemptDeadlineAt,
891
+ staleBefore: input.staleBefore,
892
+ now: new Date(),
893
+ })
894
+ ) {
895
+ return {
896
+ recording: await detailForRow(scopedDb, recording),
897
+ claimed: false,
898
+ attemptId: active.attemptId,
899
+ segment: active,
900
+ };
901
+ }
902
+ if (active) {
903
+ await scopedDb
904
+ .update(schema.transcriptionRecordingSegments)
905
+ .set({
906
+ state: "failed",
907
+ attemptId: null,
908
+ attemptStartedAt: null,
909
+ attemptDeadlineAt: null,
910
+ errorCode: "timeout",
911
+ retryable: true,
912
+ updatedAt: new Date(),
913
+ })
914
+ .where(
915
+ and(
916
+ eq(schema.transcriptionRecordingSegments.recordingId, input.recordingId),
917
+ eq(schema.transcriptionRecordingSegments.segmentNumber, active.segmentNumber),
918
+ eq(schema.transcriptionRecordingSegments.attemptId, active.attemptId!),
919
+ ),
920
+ );
921
+ }
922
+ const [candidate] = await scopedDb
923
+ .select()
924
+ .from(schema.transcriptionRecordingSegments)
925
+ .where(
926
+ and(
927
+ eq(schema.transcriptionRecordingSegments.recordingId, input.recordingId),
928
+ or(
929
+ eq(schema.transcriptionRecordingSegments.state, "pending"),
930
+ and(
931
+ eq(schema.transcriptionRecordingSegments.state, "failed"),
932
+ eq(schema.transcriptionRecordingSegments.retryable, true),
933
+ ),
934
+ ),
935
+ ),
936
+ )
937
+ .orderBy(asc(schema.transcriptionRecordingSegments.segmentNumber))
938
+ .limit(1)
939
+ .for("update");
940
+ if (!candidate) {
941
+ return {
942
+ recording: await detailForRow(scopedDb, recording),
943
+ claimed: false,
944
+ attemptId: null,
945
+ segment: null,
946
+ };
947
+ }
948
+ const providerId = recording.providerId ?? candidate.providerId ?? input.providerId;
949
+ if (
950
+ recording.providerId &&
951
+ candidate.providerId &&
952
+ recording.providerId !== candidate.providerId
953
+ ) {
954
+ throw new TranscriptionRecordingStateError("Recording provider pin is inconsistent");
955
+ }
956
+ const now = new Date();
957
+ const [claimed] = await scopedDb
958
+ .update(schema.transcriptionRecordingSegments)
959
+ .set({
960
+ state: "transcribing",
961
+ attemptId: input.attemptId,
962
+ attemptStartedAt: now,
963
+ attemptDeadlineAt: input.providerDeadlineAt,
964
+ errorCode: null,
965
+ retryable: false,
966
+ providerId,
967
+ updatedAt: now,
968
+ })
969
+ .where(
970
+ and(
971
+ eq(schema.transcriptionRecordingSegments.recordingId, input.recordingId),
972
+ eq(schema.transcriptionRecordingSegments.segmentNumber, candidate.segmentNumber),
973
+ eq(schema.transcriptionRecordingSegments.state, candidate.state),
974
+ ),
975
+ )
976
+ .returning();
977
+ if (!claimed) throw new TranscriptionRecordingStateError("Segment claim was lost");
978
+ const [updatedRecording] = await scopedDb
979
+ .update(schema.transcriptionRecordings)
980
+ .set({
981
+ state: "transcribing",
982
+ processingOwner: input.attemptId,
983
+ processingStartedAt: now,
984
+ providerId,
985
+ errorCode: null,
986
+ retryable: false,
987
+ updatedAt: now,
988
+ })
989
+ .where(eq(schema.transcriptionRecordings.id, input.recordingId))
990
+ .returning();
991
+ if (!updatedRecording) throw new TranscriptionRecordingStateError("Recording claim was lost");
992
+ recording = updatedRecording;
993
+ return {
994
+ recording: await detailForRow(scopedDb, recording),
995
+ claimed: true,
996
+ attemptId: input.attemptId,
997
+ segment: claimed,
998
+ };
999
+ });
1000
+ }
1001
+
1002
+ export async function startTranscriptionRecordingSegmentProviderCall(
1003
+ db: Database,
1004
+ input: {
1005
+ workspaceId: string;
1006
+ subjectId: string;
1007
+ recordingId: string;
1008
+ segmentNumber: number;
1009
+ attemptId: string;
1010
+ providerStartedAt: Date;
1011
+ providerDeadlineAt: Date;
1012
+ },
1013
+ ): Promise<void> {
1014
+ await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
1015
+ const recording = await requiredRecordingRow(
1016
+ scopedDb,
1017
+ input.workspaceId,
1018
+ input.recordingId,
1019
+ true,
1020
+ );
1021
+ if (recording.state !== "transcribing" || recording.processingOwner !== input.attemptId) {
1022
+ throw new TranscriptionRecordingStateError("Provider call start was stale");
1023
+ }
1024
+ // The initial attempt lease covers object reads and handler setup. Once the
1025
+ // provider call is admitted, reset the durable lease origin to this exact
1026
+ // boundary. The existing 15-minute stale fence then stays five minutes
1027
+ // beyond the server-owned 10-minute provider deadline, regardless of how
1028
+ // long pre-provider setup took.
1029
+ const [updated] = await scopedDb
1030
+ .update(schema.transcriptionRecordingSegments)
1031
+ .set({
1032
+ attemptStartedAt: input.providerStartedAt,
1033
+ attemptDeadlineAt: input.providerDeadlineAt,
1034
+ updatedAt: new Date(),
1035
+ })
1036
+ .where(
1037
+ and(
1038
+ eq(schema.transcriptionRecordingSegments.recordingId, input.recordingId),
1039
+ eq(schema.transcriptionRecordingSegments.segmentNumber, input.segmentNumber),
1040
+ eq(schema.transcriptionRecordingSegments.state, "transcribing"),
1041
+ eq(schema.transcriptionRecordingSegments.attemptId, input.attemptId),
1042
+ ),
1043
+ )
1044
+ .returning({ segmentNumber: schema.transcriptionRecordingSegments.segmentNumber });
1045
+ if (!updated) throw new TranscriptionRecordingStateError("Provider call start was stale");
1046
+ const [updatedRecording] = await scopedDb
1047
+ .update(schema.transcriptionRecordings)
1048
+ .set({ processingStartedAt: input.providerStartedAt, updatedAt: new Date() })
1049
+ .where(
1050
+ and(
1051
+ eq(schema.transcriptionRecordings.id, input.recordingId),
1052
+ eq(schema.transcriptionRecordings.state, "transcribing"),
1053
+ eq(schema.transcriptionRecordings.processingOwner, input.attemptId),
1054
+ ),
1055
+ )
1056
+ .returning({ id: schema.transcriptionRecordings.id });
1057
+ if (!updatedRecording)
1058
+ throw new TranscriptionRecordingStateError("Provider call start was stale");
1059
+ });
1060
+ }
1061
+
1062
+ export function assembleTranscriptionSegments(
1063
+ segments: readonly Pick<SegmentRow, "segmentNumber" | "transcriptText" | "languages">[],
1064
+ ): { text: string; languages: string[] } {
1065
+ const ordered = [...segments].sort((left, right) => left.segmentNumber - right.segmentNumber);
1066
+ const text = ordered
1067
+ .map((segment) => segment.transcriptText?.trim() ?? "")
1068
+ .filter(Boolean)
1069
+ .join("\n\n");
1070
+ const seen = new Set<string>();
1071
+ const combinedLanguages: string[] = [];
1072
+ for (const segment of ordered) {
1073
+ for (const language of languages(segment.languages)) {
1074
+ const normalized = language.trim();
1075
+ if (!normalized || seen.has(normalized)) continue;
1076
+ seen.add(normalized);
1077
+ combinedLanguages.push(normalized);
1078
+ }
1079
+ }
1080
+ return { text, languages: combinedLanguages };
1081
+ }
1082
+
1083
+ export async function completeTranscriptionRecordingSegment(
1084
+ db: Database,
1085
+ input: {
1086
+ workspaceId: string;
1087
+ subjectId: string;
1088
+ recordingId: string;
1089
+ segmentNumber: number;
1090
+ attemptId: string;
1091
+ text: string;
1092
+ languages: string[];
1093
+ providerId: string;
1094
+ },
1095
+ ): Promise<TranscriptionRecordingResponse> {
1096
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
1097
+ const recording = await requiredRecordingRow(
1098
+ scopedDb,
1099
+ input.workspaceId,
1100
+ input.recordingId,
1101
+ true,
1102
+ );
1103
+ if (recording.state !== "transcribing" || recording.processingOwner !== input.attemptId) {
1104
+ throw new TranscriptionRecordingStateError("Segment completion was stale");
1105
+ }
1106
+ const now = new Date();
1107
+ const [completed] = await scopedDb
1108
+ .update(schema.transcriptionRecordingSegments)
1109
+ .set({
1110
+ state: "complete",
1111
+ attemptId: null,
1112
+ attemptStartedAt: null,
1113
+ attemptDeadlineAt: null,
1114
+ transcriptText: input.text,
1115
+ languages: input.languages,
1116
+ providerId: input.providerId,
1117
+ errorCode: null,
1118
+ retryable: false,
1119
+ updatedAt: now,
1120
+ })
1121
+ .where(
1122
+ and(
1123
+ eq(schema.transcriptionRecordingSegments.recordingId, input.recordingId),
1124
+ eq(schema.transcriptionRecordingSegments.segmentNumber, input.segmentNumber),
1125
+ eq(schema.transcriptionRecordingSegments.state, "transcribing"),
1126
+ eq(schema.transcriptionRecordingSegments.attemptId, input.attemptId),
1127
+ ),
1128
+ )
1129
+ .returning();
1130
+ if (!completed) throw new TranscriptionRecordingStateError("Segment completion was stale");
1131
+ const segments = await scopedDb
1132
+ .select()
1133
+ .from(schema.transcriptionRecordingSegments)
1134
+ .where(eq(schema.transcriptionRecordingSegments.recordingId, input.recordingId))
1135
+ .orderBy(asc(schema.transcriptionRecordingSegments.segmentNumber));
1136
+ const completedCount = segments.filter((segment) => segment.state === "complete").length;
1137
+ const allComplete = completedCount === segments.length && segments.length > 0;
1138
+ const assembled = allComplete ? assembleTranscriptionSegments(segments) : null;
1139
+ const [updated] = await scopedDb
1140
+ .update(schema.transcriptionRecordings)
1141
+ .set({
1142
+ state: allComplete ? "complete" : "ready",
1143
+ completedSegmentCount: completedCount,
1144
+ transcriptText: assembled?.text ?? null,
1145
+ languages: assembled?.languages ?? [],
1146
+ processingOwner: null,
1147
+ processingStartedAt: null,
1148
+ errorCode: null,
1149
+ retryable: false,
1150
+ updatedAt: now,
1151
+ })
1152
+ .where(
1153
+ and(
1154
+ eq(schema.transcriptionRecordings.id, input.recordingId),
1155
+ eq(schema.transcriptionRecordings.processingOwner, input.attemptId),
1156
+ ),
1157
+ )
1158
+ .returning();
1159
+ if (!updated) throw new TranscriptionRecordingStateError("Recording completion was stale");
1160
+ if (allComplete) {
1161
+ await scopedDb
1162
+ .update(schema.transcriptionRecordingObjects)
1163
+ .set({ cleanupAfter: now })
1164
+ .where(eq(schema.transcriptionRecordingObjects.recordingId, input.recordingId));
1165
+ }
1166
+ return await detailForRow(scopedDb, updated);
1167
+ });
1168
+ }
1169
+
1170
+ export async function failTranscriptionRecordingSegment(
1171
+ db: Database,
1172
+ input: {
1173
+ workspaceId: string;
1174
+ subjectId: string;
1175
+ recordingId: string;
1176
+ segmentNumber: number;
1177
+ attemptId: string;
1178
+ errorCode: TranscriptionRecordingErrorCode;
1179
+ retryable: boolean;
1180
+ },
1181
+ ): Promise<TranscriptionRecordingResponse> {
1182
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
1183
+ const recording = await requiredRecordingRow(
1184
+ scopedDb,
1185
+ input.workspaceId,
1186
+ input.recordingId,
1187
+ true,
1188
+ );
1189
+ if (recording.state !== "transcribing" || recording.processingOwner !== input.attemptId) {
1190
+ throw new TranscriptionRecordingStateError("Segment failure was stale");
1191
+ }
1192
+ const now = new Date();
1193
+ const [failed] = await scopedDb
1194
+ .update(schema.transcriptionRecordingSegments)
1195
+ .set({
1196
+ state: "failed",
1197
+ attemptId: null,
1198
+ attemptStartedAt: null,
1199
+ attemptDeadlineAt: null,
1200
+ errorCode: input.errorCode,
1201
+ retryable: input.retryable,
1202
+ updatedAt: now,
1203
+ })
1204
+ .where(
1205
+ and(
1206
+ eq(schema.transcriptionRecordingSegments.recordingId, input.recordingId),
1207
+ eq(schema.transcriptionRecordingSegments.segmentNumber, input.segmentNumber),
1208
+ eq(schema.transcriptionRecordingSegments.state, "transcribing"),
1209
+ eq(schema.transcriptionRecordingSegments.attemptId, input.attemptId),
1210
+ ),
1211
+ )
1212
+ .returning();
1213
+ if (!failed) throw new TranscriptionRecordingStateError("Segment failure was stale");
1214
+ const [updated] = await scopedDb
1215
+ .update(schema.transcriptionRecordings)
1216
+ .set({
1217
+ state: "failed",
1218
+ processingOwner: null,
1219
+ processingStartedAt: null,
1220
+ errorCode: input.errorCode,
1221
+ retryable: input.retryable,
1222
+ updatedAt: now,
1223
+ })
1224
+ .where(
1225
+ and(
1226
+ eq(schema.transcriptionRecordings.id, input.recordingId),
1227
+ eq(schema.transcriptionRecordings.processingOwner, input.attemptId),
1228
+ ),
1229
+ )
1230
+ .returning();
1231
+ if (!updated) throw new TranscriptionRecordingStateError("Recording failure was stale");
1232
+ if (!input.retryable) {
1233
+ await scopedDb
1234
+ .update(schema.transcriptionRecordingObjects)
1235
+ .set({ cleanupAfter: now })
1236
+ .where(eq(schema.transcriptionRecordingObjects.recordingId, input.recordingId));
1237
+ }
1238
+ return await detailForRow(scopedDb, updated);
1239
+ });
1240
+ }
1241
+
1242
+ export async function transcriptionRecordingObjectKeys(
1243
+ db: Database,
1244
+ input: { workspaceId: string; subjectId: string; recordingId: string },
1245
+ ): Promise<string[]> {
1246
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
1247
+ await requiredRecordingRow(scopedDb, input.workspaceId, input.recordingId);
1248
+ const objects = await scopedDb
1249
+ .select({ objectKey: schema.transcriptionRecordingObjects.objectKey })
1250
+ .from(schema.transcriptionRecordingObjects)
1251
+ .where(
1252
+ and(
1253
+ eq(schema.transcriptionRecordingObjects.recordingId, input.recordingId),
1254
+ isNull(schema.transcriptionRecordingObjects.cleanedAt),
1255
+ ),
1256
+ )
1257
+ .orderBy(asc(schema.transcriptionRecordingObjects.objectKey));
1258
+ return objects.map((entry) => entry.objectKey);
1259
+ });
1260
+ }
1261
+
1262
+ async function settleTranscriptionRecordingObjectsCleaned(
1263
+ scopedDb: Database,
1264
+ recording: RecordingRow,
1265
+ ): Promise<RecordingRow> {
1266
+ const [remaining] = await scopedDb
1267
+ .select({ objectKey: schema.transcriptionRecordingObjects.objectKey })
1268
+ .from(schema.transcriptionRecordingObjects)
1269
+ .where(
1270
+ and(
1271
+ eq(schema.transcriptionRecordingObjects.recordingId, recording.id),
1272
+ isNull(schema.transcriptionRecordingObjects.cleanedAt),
1273
+ ),
1274
+ )
1275
+ .limit(1);
1276
+ if (remaining) return recording;
1277
+ const expired = recording.expiresAt.getTime() <= Date.now();
1278
+ const terminal =
1279
+ recording.state === "complete" ||
1280
+ recording.state === "discarded" ||
1281
+ (recording.state === "failed" && !recording.retryable);
1282
+ if (!expired && !terminal) return recording;
1283
+ const now = new Date();
1284
+ const [updated] = await scopedDb
1285
+ .update(schema.transcriptionRecordings)
1286
+ .set({
1287
+ ...(expired
1288
+ ? {
1289
+ state: "discarded" as const,
1290
+ processingOwner: null,
1291
+ processingStartedAt: null,
1292
+ errorCode: null,
1293
+ retryable: false,
1294
+ }
1295
+ : {}),
1296
+ objectsCleanedAt: now,
1297
+ updatedAt: now,
1298
+ })
1299
+ .where(eq(schema.transcriptionRecordings.id, recording.id))
1300
+ .returning();
1301
+ if (!updated) throw new TranscriptionRecordingStateError("Object cleanup settlement was lost");
1302
+ return updated;
1303
+ }
1304
+
1305
+ export async function markTranscriptionRecordingObjectCleaned(
1306
+ db: Database,
1307
+ input: { workspaceId: string; subjectId: string; recordingId: string; objectKey: string },
1308
+ ): Promise<TranscriptionRecordingResponse> {
1309
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
1310
+ let recording = await requiredRecordingRow(
1311
+ scopedDb,
1312
+ input.workspaceId,
1313
+ input.recordingId,
1314
+ true,
1315
+ );
1316
+ const [object] = await scopedDb
1317
+ .select()
1318
+ .from(schema.transcriptionRecordingObjects)
1319
+ .where(
1320
+ and(
1321
+ eq(schema.transcriptionRecordingObjects.recordingId, input.recordingId),
1322
+ eq(schema.transcriptionRecordingObjects.objectKey, input.objectKey),
1323
+ ),
1324
+ )
1325
+ .limit(1)
1326
+ .for("update");
1327
+ if (!object) throw new TranscriptionRecordingNotFoundError("Recording object not found");
1328
+ if (!object.cleanedAt) {
1329
+ const now = new Date();
1330
+ const [updated] = await scopedDb
1331
+ .update(schema.transcriptionRecordingObjects)
1332
+ .set({
1333
+ cleanupClaimId: null,
1334
+ cleanupClaimedAt: null,
1335
+ cleanedAt: now,
1336
+ })
1337
+ .where(eq(schema.transcriptionRecordingObjects.objectKey, input.objectKey))
1338
+ .returning({ objectKey: schema.transcriptionRecordingObjects.objectKey });
1339
+ if (!updated) throw new TranscriptionRecordingStateError("Object cleanup was lost");
1340
+ }
1341
+ recording = await settleTranscriptionRecordingObjectsCleaned(scopedDb, recording);
1342
+ return await detailForRow(scopedDb, recording);
1343
+ });
1344
+ }
1345
+
1346
+ export async function markTranscriptionRecordingObjectsCleaned(
1347
+ db: Database,
1348
+ input: { workspaceId: string; subjectId: string; recordingId: string },
1349
+ ): Promise<TranscriptionRecordingResponse> {
1350
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
1351
+ let recording = await requiredRecordingRow(
1352
+ scopedDb,
1353
+ input.workspaceId,
1354
+ input.recordingId,
1355
+ true,
1356
+ );
1357
+ const [remaining] = await scopedDb
1358
+ .select({ objectKey: schema.transcriptionRecordingObjects.objectKey })
1359
+ .from(schema.transcriptionRecordingObjects)
1360
+ .where(
1361
+ and(
1362
+ eq(schema.transcriptionRecordingObjects.recordingId, input.recordingId),
1363
+ isNull(schema.transcriptionRecordingObjects.cleanedAt),
1364
+ ),
1365
+ )
1366
+ .limit(1);
1367
+ if (remaining) {
1368
+ throw new TranscriptionRecordingStateError("Recording objects are not cleaned");
1369
+ }
1370
+ recording = await settleTranscriptionRecordingObjectsCleaned(scopedDb, recording);
1371
+ if (!recording.objectsCleanedAt) {
1372
+ throw new TranscriptionRecordingStateError("Recording is not terminal");
1373
+ }
1374
+ return await detailForRow(scopedDb, recording);
1375
+ });
1376
+ }
1377
+
1378
+ export async function discardTranscriptionRecording(
1379
+ db: Database,
1380
+ input: { workspaceId: string; subjectId: string; recordingId: string },
1381
+ ): Promise<TranscriptionRecordingResponse> {
1382
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
1383
+ const row = await requiredRecordingRow(scopedDb, input.workspaceId, input.recordingId, true);
1384
+ if (row.state === "discarded") return await detailForRow(scopedDb, row);
1385
+ const [updated] = await scopedDb
1386
+ .update(schema.transcriptionRecordings)
1387
+ .set({
1388
+ state: "discarded",
1389
+ processingOwner: null,
1390
+ processingStartedAt: null,
1391
+ errorCode: null,
1392
+ retryable: false,
1393
+ updatedAt: new Date(),
1394
+ })
1395
+ .where(eq(schema.transcriptionRecordings.id, input.recordingId))
1396
+ .returning();
1397
+ if (!updated) throw new TranscriptionRecordingStateError("Discard was lost");
1398
+ await scopedDb
1399
+ .update(schema.transcriptionRecordingObjects)
1400
+ .set({ cleanupAfter: new Date() })
1401
+ .where(eq(schema.transcriptionRecordingObjects.recordingId, input.recordingId));
1402
+ return await detailForRow(scopedDb, updated);
1403
+ });
1404
+ }
1405
+
1406
+ export async function claimDueTranscriptionRecordingObjectCleanup(
1407
+ db: Database,
1408
+ input: { graceMs: number; claimTimeoutMs: number; limit: number },
1409
+ ): Promise<TranscriptionRecordingObjectCleanupClaim[]> {
1410
+ if (
1411
+ !Number.isSafeInteger(input.graceMs) ||
1412
+ input.graceMs < 0 ||
1413
+ !Number.isSafeInteger(input.claimTimeoutMs) ||
1414
+ input.claimTimeoutMs < 0 ||
1415
+ !Number.isSafeInteger(input.limit) ||
1416
+ input.limit <= 0
1417
+ ) {
1418
+ throw new Error("Invalid transcription recording cleanup claim bounds");
1419
+ }
1420
+ type ClaimRow = {
1421
+ account_id: string;
1422
+ workspace_id: string;
1423
+ subject_id: string;
1424
+ recording_id: string;
1425
+ object_key: string;
1426
+ cleanup_claim_id: string;
1427
+ };
1428
+ const rows = await db.execute<ClaimRow>(sql`
1429
+ select account_id, workspace_id, subject_id, recording_id, object_key, cleanup_claim_id
1430
+ from opengeni_private.claim_due_transcription_recording_object_cleanup(
1431
+ ${input.graceMs},
1432
+ ${input.claimTimeoutMs},
1433
+ ${input.limit}
1434
+ )
1435
+ `);
1436
+ return rows.map((row: ClaimRow) => ({
1437
+ accountId: row.account_id,
1438
+ workspaceId: row.workspace_id,
1439
+ subjectId: row.subject_id,
1440
+ recordingId: row.recording_id,
1441
+ objectKey: row.object_key,
1442
+ cleanupClaimId: row.cleanup_claim_id,
1443
+ }));
1444
+ }
1445
+
1446
+ export async function completeDueTranscriptionRecordingObjectCleanup(
1447
+ db: Database,
1448
+ input: TranscriptionRecordingObjectCleanupClaim,
1449
+ ): Promise<boolean> {
1450
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
1451
+ let recording = await requiredRecordingRow(
1452
+ scopedDb,
1453
+ input.workspaceId,
1454
+ input.recordingId,
1455
+ true,
1456
+ );
1457
+ const [object] = await scopedDb
1458
+ .select()
1459
+ .from(schema.transcriptionRecordingObjects)
1460
+ .where(
1461
+ and(
1462
+ eq(schema.transcriptionRecordingObjects.recordingId, input.recordingId),
1463
+ eq(schema.transcriptionRecordingObjects.objectKey, input.objectKey),
1464
+ ),
1465
+ )
1466
+ .limit(1)
1467
+ .for("update");
1468
+ if (!object) return false;
1469
+ if (object.cleanedAt) return true;
1470
+ if (object.cleanupClaimId !== input.cleanupClaimId) return false;
1471
+ const now = new Date();
1472
+ const [updated] = await scopedDb
1473
+ .update(schema.transcriptionRecordingObjects)
1474
+ .set({ cleanupClaimId: null, cleanupClaimedAt: null, cleanedAt: now })
1475
+ .where(
1476
+ and(
1477
+ eq(schema.transcriptionRecordingObjects.objectKey, input.objectKey),
1478
+ eq(schema.transcriptionRecordingObjects.cleanupClaimId, input.cleanupClaimId),
1479
+ ),
1480
+ )
1481
+ .returning({ objectKey: schema.transcriptionRecordingObjects.objectKey });
1482
+ if (!updated) return false;
1483
+ recording = await settleTranscriptionRecordingObjectsCleaned(scopedDb, recording);
1484
+ return Boolean(recording.objectsCleanedAt || object.cleanedAt || updated);
1485
+ });
1486
+ }
1487
+
1488
+ export async function purgeExpiredTranscriptionRecordings(
1489
+ db: Database,
1490
+ input: { graceMs: number; limit: number },
1491
+ ): Promise<number> {
1492
+ if (
1493
+ !Number.isSafeInteger(input.graceMs) ||
1494
+ input.graceMs < 0 ||
1495
+ !Number.isSafeInteger(input.limit) ||
1496
+ input.limit <= 0
1497
+ ) {
1498
+ throw new Error("Invalid transcription recording purge bounds");
1499
+ }
1500
+ type PurgeRow = { purged_count: number };
1501
+ const [row] = await db.execute<PurgeRow>(sql`
1502
+ select opengeni_private.purge_expired_transcription_recordings(
1503
+ ${input.graceMs},
1504
+ ${input.limit}
1505
+ ) as purged_count
1506
+ `);
1507
+ return Number(row?.purged_count ?? 0);
1508
+ }