@opengeni/db 0.27.11 → 0.28.9

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