@opengeni/db 0.27.9 → 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.
- package/dist/{chunk-2JFRTKTG.js → chunk-DBS5HPEW.js} +11 -1
- package/dist/chunk-DBS5HPEW.js.map +1 -0
- package/dist/{chunk-NX2JNJJP.js → chunk-HT5T62CC.js} +2538 -2283
- package/dist/chunk-HT5T62CC.js.map +1 -0
- package/dist/index.d.ts +69 -36
- package/dist/index.js +4146 -2924
- package/dist/index.js.map +1 -1
- package/dist/provision-roles.js +1 -1
- package/dist/runtime-posture.d.ts +2 -2
- package/dist/schema.d.ts +129 -96
- package/dist/schema.js +19 -1
- package/dist/transcription-recordings-schema.d.ts +1226 -0
- package/dist/transcription-recordings.d.ts +228 -0
- package/drizzle/0170_resumable_transcription_recordings.sql +440 -0
- package/drizzle/0172_retire_model_visible_github_token.sql +86 -0
- package/drizzle/0173_codex_auth_boundaries.sql +107 -0
- package/drizzle/0174_session_wake_live_interruption.sql +80 -0
- package/drizzle/0175_resumable_transcription_provider_deadline.sql +24 -0
- package/package.json +4 -4
- package/src/index.ts +585 -245
- package/src/runtime-posture.ts +10 -0
- package/src/schema.ts +63 -51
- package/src/transcription-recordings-schema.ts +250 -0
- package/src/transcription-recordings.ts +1508 -0
- package/dist/chunk-2JFRTKTG.js.map +0 -1
- package/dist/chunk-NX2JNJJP.js.map +0 -1
|
@@ -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
|
+
}
|