@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.
@@ -0,0 +1,228 @@
1
+ import type { TranscriptionRecording, TranscriptionRecordingErrorCode, TranscriptionRecordingResponse } from "@opengeni/contracts";
2
+ import type { Database } from "./database";
3
+ import * as schema from "./schema";
4
+ type ChunkRow = typeof schema.transcriptionRecordingChunks.$inferSelect;
5
+ type SegmentRow = typeof schema.transcriptionRecordingSegments.$inferSelect;
6
+ export declare class TranscriptionRecordingNotFoundError extends Error {
7
+ readonly name = "TranscriptionRecordingNotFoundError";
8
+ }
9
+ export declare class TranscriptionRecordingConflictError extends Error {
10
+ readonly name = "TranscriptionRecordingConflictError";
11
+ }
12
+ export declare class TranscriptionRecordingStateError extends Error {
13
+ readonly name = "TranscriptionRecordingStateError";
14
+ }
15
+ export type TranscriptionRecordingChunkReservation = {
16
+ recording: TranscriptionRecordingResponse;
17
+ chunk: ChunkRow;
18
+ deduplicated: boolean;
19
+ };
20
+ export type TranscriptionRecordingAssemblyClaim = {
21
+ recording: TranscriptionRecordingResponse;
22
+ claimed: boolean;
23
+ generation: number;
24
+ owner: string | null;
25
+ staleObjectKeys: string[];
26
+ };
27
+ export type TranscriptionRecordingSegmentClaim = {
28
+ recording: TranscriptionRecordingResponse;
29
+ claimed: boolean;
30
+ attemptId: string | null;
31
+ segment: SegmentRow | null;
32
+ };
33
+ export type TranscriptionRecordingObjectCleanupClaim = {
34
+ accountId: string;
35
+ workspaceId: string;
36
+ subjectId: string;
37
+ recordingId: string;
38
+ objectKey: string;
39
+ cleanupClaimId: string;
40
+ };
41
+ /**
42
+ * A stale attempt is reclaimable only after both durable fences have expired:
43
+ * the processing lease and the server-owned provider deadline. Missing
44
+ * deadlines fail closed so an old pre-deadline row cannot create an overlap.
45
+ */
46
+ export declare function canReclaimTranscriptionRecordingAttempt(input: {
47
+ attemptStartedAt: Date | null;
48
+ attemptDeadlineAt: Date | null;
49
+ staleBefore: Date;
50
+ now: Date;
51
+ }): boolean;
52
+ export declare function transcriptionRecordingChunkObjectKey(input: {
53
+ accountId: string;
54
+ workspaceId: string;
55
+ recordingId: string;
56
+ chunkNumber: number;
57
+ sha256: string;
58
+ }): string;
59
+ export declare function transcriptionRecordingSegmentObjectKey(input: {
60
+ accountId: string;
61
+ workspaceId: string;
62
+ recordingId: string;
63
+ generation: number;
64
+ segmentNumber: number;
65
+ sha256: string;
66
+ }): string;
67
+ export declare function createTranscriptionRecording(db: Database, input: {
68
+ accountId: string;
69
+ workspaceId: string;
70
+ subjectId: string;
71
+ recordingId: string;
72
+ mimeType: string;
73
+ expiresAt: Date;
74
+ }): Promise<TranscriptionRecordingResponse>;
75
+ export declare function getTranscriptionRecording(db: Database, input: {
76
+ workspaceId: string;
77
+ subjectId: string;
78
+ recordingId: string;
79
+ }): Promise<TranscriptionRecordingResponse>;
80
+ export declare function listTranscriptionRecordings(db: Database, input: {
81
+ workspaceId: string;
82
+ subjectId: string;
83
+ limit?: number;
84
+ }): Promise<TranscriptionRecording[]>;
85
+ export declare function reserveTranscriptionRecordingChunk(db: Database, input: {
86
+ accountId: string;
87
+ workspaceId: string;
88
+ subjectId: string;
89
+ recordingId: string;
90
+ chunkNumber: number;
91
+ byteLength: number;
92
+ sha256: string;
93
+ startMilliseconds: number;
94
+ durationMilliseconds: number;
95
+ maxTotalBytes: number;
96
+ maxDurationMilliseconds: number;
97
+ }): Promise<TranscriptionRecordingChunkReservation>;
98
+ export declare function completeTranscriptionRecordingChunk(db: Database, input: {
99
+ workspaceId: string;
100
+ subjectId: string;
101
+ recordingId: string;
102
+ chunkNumber: number;
103
+ }): Promise<TranscriptionRecordingChunkReservation>;
104
+ export declare function claimTranscriptionRecordingAssembly(db: Database, input: {
105
+ workspaceId: string;
106
+ subjectId: string;
107
+ recordingId: string;
108
+ owner: string;
109
+ chunkCount: number;
110
+ totalBytes: number;
111
+ totalDurationMilliseconds: number;
112
+ staleBefore: Date;
113
+ }): Promise<TranscriptionRecordingAssemblyClaim>;
114
+ export declare function listTranscriptionRecordingChunks(db: Database, input: {
115
+ workspaceId: string;
116
+ subjectId: string;
117
+ recordingId: string;
118
+ }): Promise<ChunkRow[]>;
119
+ export declare function reserveTranscriptionRecordingSegment(db: Database, input: {
120
+ accountId: string;
121
+ workspaceId: string;
122
+ subjectId: string;
123
+ recordingId: string;
124
+ owner: string;
125
+ generation: number;
126
+ segmentNumber: number;
127
+ byteLength: number;
128
+ sha256: string;
129
+ startMilliseconds: number;
130
+ durationMilliseconds: number;
131
+ }): Promise<SegmentRow>;
132
+ export declare function completeTranscriptionRecordingSegmentPreparation(db: Database, input: {
133
+ workspaceId: string;
134
+ subjectId: string;
135
+ recordingId: string;
136
+ owner: string;
137
+ generation: number;
138
+ segmentNumber: number;
139
+ }): Promise<void>;
140
+ export declare function completeTranscriptionRecordingAssembly(db: Database, input: {
141
+ workspaceId: string;
142
+ subjectId: string;
143
+ recordingId: string;
144
+ owner: string;
145
+ generation: number;
146
+ }): Promise<TranscriptionRecordingResponse>;
147
+ export declare function failTranscriptionRecordingAssembly(db: Database, input: {
148
+ workspaceId: string;
149
+ subjectId: string;
150
+ recordingId: string;
151
+ owner: string;
152
+ generation: number;
153
+ errorCode: TranscriptionRecordingErrorCode;
154
+ retryable: boolean;
155
+ }): Promise<TranscriptionRecordingResponse>;
156
+ export declare function claimNextTranscriptionRecordingSegment(db: Database, input: {
157
+ workspaceId: string;
158
+ subjectId: string;
159
+ recordingId: string;
160
+ attemptId: string;
161
+ providerId: string;
162
+ staleBefore: Date;
163
+ providerDeadlineAt: Date;
164
+ }): Promise<TranscriptionRecordingSegmentClaim>;
165
+ export declare function startTranscriptionRecordingSegmentProviderCall(db: Database, input: {
166
+ workspaceId: string;
167
+ subjectId: string;
168
+ recordingId: string;
169
+ segmentNumber: number;
170
+ attemptId: string;
171
+ providerStartedAt: Date;
172
+ providerDeadlineAt: Date;
173
+ }): Promise<void>;
174
+ export declare function assembleTranscriptionSegments(segments: readonly Pick<SegmentRow, "segmentNumber" | "transcriptText" | "languages">[]): {
175
+ text: string;
176
+ languages: string[];
177
+ };
178
+ export declare function completeTranscriptionRecordingSegment(db: Database, input: {
179
+ workspaceId: string;
180
+ subjectId: string;
181
+ recordingId: string;
182
+ segmentNumber: number;
183
+ attemptId: string;
184
+ text: string;
185
+ languages: string[];
186
+ providerId: string;
187
+ }): Promise<TranscriptionRecordingResponse>;
188
+ export declare function failTranscriptionRecordingSegment(db: Database, input: {
189
+ workspaceId: string;
190
+ subjectId: string;
191
+ recordingId: string;
192
+ segmentNumber: number;
193
+ attemptId: string;
194
+ errorCode: TranscriptionRecordingErrorCode;
195
+ retryable: boolean;
196
+ }): Promise<TranscriptionRecordingResponse>;
197
+ export declare function transcriptionRecordingObjectKeys(db: Database, input: {
198
+ workspaceId: string;
199
+ subjectId: string;
200
+ recordingId: string;
201
+ }): Promise<string[]>;
202
+ export declare function markTranscriptionRecordingObjectCleaned(db: Database, input: {
203
+ workspaceId: string;
204
+ subjectId: string;
205
+ recordingId: string;
206
+ objectKey: string;
207
+ }): Promise<TranscriptionRecordingResponse>;
208
+ export declare function markTranscriptionRecordingObjectsCleaned(db: Database, input: {
209
+ workspaceId: string;
210
+ subjectId: string;
211
+ recordingId: string;
212
+ }): Promise<TranscriptionRecordingResponse>;
213
+ export declare function discardTranscriptionRecording(db: Database, input: {
214
+ workspaceId: string;
215
+ subjectId: string;
216
+ recordingId: string;
217
+ }): Promise<TranscriptionRecordingResponse>;
218
+ export declare function claimDueTranscriptionRecordingObjectCleanup(db: Database, input: {
219
+ graceMs: number;
220
+ claimTimeoutMs: number;
221
+ limit: number;
222
+ }): Promise<TranscriptionRecordingObjectCleanupClaim[]>;
223
+ export declare function completeDueTranscriptionRecordingObjectCleanup(db: Database, input: TranscriptionRecordingObjectCleanupClaim): Promise<boolean>;
224
+ export declare function purgeExpiredTranscriptionRecordings(db: Database, input: {
225
+ graceMs: number;
226
+ limit: number;
227
+ }): Promise<number>;
228
+ export {};
@@ -0,0 +1,440 @@
1
+ -- deployment-mode: rolling
2
+
3
+ CREATE TABLE "transcription_recordings" (
4
+ "id" uuid PRIMARY KEY,
5
+ "account_id" uuid NOT NULL REFERENCES "managed_accounts"("id") ON DELETE CASCADE,
6
+ "workspace_id" uuid NOT NULL REFERENCES "workspaces"("id") ON DELETE CASCADE,
7
+ "subject_id" text NOT NULL,
8
+ "mime_type" text NOT NULL,
9
+ "state" text NOT NULL DEFAULT 'uploading',
10
+ "next_chunk_number" integer NOT NULL DEFAULT 0,
11
+ "chunk_count" integer NOT NULL DEFAULT 0,
12
+ "total_bytes" integer NOT NULL DEFAULT 0,
13
+ "total_duration_milliseconds" integer NOT NULL DEFAULT 0,
14
+ "segment_count" integer NOT NULL DEFAULT 0,
15
+ "completed_segment_count" integer NOT NULL DEFAULT 0,
16
+ "transcript_text" text,
17
+ "languages" jsonb NOT NULL DEFAULT '[]'::jsonb,
18
+ "error_code" text,
19
+ "retryable" boolean NOT NULL DEFAULT false,
20
+ "provider_id" text,
21
+ "processing_generation" integer NOT NULL DEFAULT 0,
22
+ "processing_owner" uuid,
23
+ "processing_started_at" timestamptz,
24
+ "objects_cleaned_at" timestamptz,
25
+ "created_at" timestamptz NOT NULL DEFAULT now(),
26
+ "updated_at" timestamptz NOT NULL DEFAULT now(),
27
+ "expires_at" timestamptz NOT NULL,
28
+ CONSTRAINT "transcription_recordings_workspace_account_fk"
29
+ FOREIGN KEY ("workspace_id", "account_id")
30
+ REFERENCES "workspaces"("id", "account_id") ON DELETE CASCADE,
31
+ CONSTRAINT "transcription_recordings_exact_authority_uq"
32
+ UNIQUE ("account_id", "workspace_id", "subject_id", "id"),
33
+ CONSTRAINT "transcription_recordings_state_check"
34
+ CHECK ("state" IN (
35
+ 'uploading', 'segmenting', 'ready', 'transcribing', 'complete', 'failed', 'discarded'
36
+ )),
37
+ CONSTRAINT "transcription_recordings_values_check"
38
+ CHECK (
39
+ "next_chunk_number" >= 0
40
+ AND "chunk_count" >= 0
41
+ AND "total_bytes" >= 0
42
+ AND "total_duration_milliseconds" >= 0
43
+ AND "segment_count" >= 0
44
+ AND "completed_segment_count" >= 0
45
+ AND "completed_segment_count" <= "segment_count"
46
+ AND octet_length("subject_id") BETWEEN 1 AND 1024
47
+ AND octet_length("mime_type") BETWEEN 1 AND 128
48
+ AND ("provider_id" IS NULL OR octet_length("provider_id") BETWEEN 1 AND 128)
49
+ AND ("transcript_text" IS NULL OR octet_length("transcript_text") <= 4000000)
50
+ AND jsonb_typeof("languages") = 'array'
51
+ ),
52
+ CONSTRAINT "transcription_recordings_error_code_check"
53
+ CHECK ("error_code" IS NULL OR "error_code" IN (
54
+ 'permission_denied', 'not_supported', 'network', 'provider', 'policy_blocked',
55
+ 'timeout', 'cancelled', 'unavailable', 'too_large', 'invalid_audio', 'unknown'
56
+ )),
57
+ CONSTRAINT "transcription_recordings_processing_check"
58
+ CHECK (
59
+ ("processing_owner" IS NULL AND "processing_started_at" IS NULL)
60
+ OR ("processing_owner" IS NOT NULL AND "processing_started_at" IS NOT NULL)
61
+ )
62
+ );
63
+
64
+ CREATE INDEX "transcription_recordings_subject_created_idx"
65
+ ON "transcription_recordings" ("workspace_id", "subject_id", "created_at");
66
+ CREATE INDEX "transcription_recordings_expiry_idx"
67
+ ON "transcription_recordings" ("expires_at", "id");
68
+
69
+ CREATE TABLE "transcription_recording_objects" (
70
+ "account_id" uuid NOT NULL,
71
+ "workspace_id" uuid NOT NULL,
72
+ "subject_id" text NOT NULL,
73
+ "recording_id" uuid NOT NULL,
74
+ "object_key" text PRIMARY KEY,
75
+ "kind" text NOT NULL,
76
+ "cleanup_after" timestamptz NOT NULL,
77
+ "cleanup_claim_id" uuid,
78
+ "cleanup_claimed_at" timestamptz,
79
+ "cleaned_at" timestamptz,
80
+ "created_at" timestamptz NOT NULL DEFAULT now(),
81
+ CONSTRAINT "transcription_recording_objects_authority_fk"
82
+ FOREIGN KEY ("account_id", "workspace_id", "subject_id", "recording_id")
83
+ REFERENCES "transcription_recordings"("account_id", "workspace_id", "subject_id", "id")
84
+ ON DELETE CASCADE,
85
+ CONSTRAINT "transcription_recording_objects_kind_check"
86
+ CHECK ("kind" IN ('chunk', 'segment')),
87
+ CONSTRAINT "transcription_recording_objects_values_check"
88
+ CHECK (
89
+ octet_length("object_key") BETWEEN 1 AND 1024
90
+ AND (
91
+ ("cleanup_claim_id" IS NULL AND "cleanup_claimed_at" IS NULL)
92
+ OR ("cleanup_claim_id" IS NOT NULL AND "cleanup_claimed_at" IS NOT NULL)
93
+ )
94
+ AND (
95
+ "cleaned_at" IS NULL
96
+ OR ("cleanup_claim_id" IS NULL AND "cleanup_claimed_at" IS NULL)
97
+ )
98
+ )
99
+ );
100
+
101
+ CREATE INDEX "transcription_recording_objects_due_cleanup_idx"
102
+ ON "transcription_recording_objects" ("cleanup_after", "object_key")
103
+ WHERE "cleaned_at" IS NULL;
104
+ CREATE INDEX "transcription_recording_objects_claim_recovery_idx"
105
+ ON "transcription_recording_objects" ("cleanup_claimed_at", "object_key")
106
+ WHERE "cleaned_at" IS NULL AND "cleanup_claim_id" IS NOT NULL;
107
+
108
+ CREATE TABLE "transcription_recording_chunks" (
109
+ "account_id" uuid NOT NULL,
110
+ "workspace_id" uuid NOT NULL,
111
+ "subject_id" text NOT NULL,
112
+ "recording_id" uuid NOT NULL,
113
+ "chunk_number" integer NOT NULL,
114
+ "state" text NOT NULL DEFAULT 'uploading',
115
+ "byte_length" integer NOT NULL,
116
+ "sha256" text NOT NULL,
117
+ "start_milliseconds" integer NOT NULL,
118
+ "duration_milliseconds" integer NOT NULL,
119
+ "object_key" text NOT NULL,
120
+ "created_at" timestamptz NOT NULL DEFAULT now(),
121
+ "completed_at" timestamptz,
122
+ CONSTRAINT "transcription_recording_chunks_pk" PRIMARY KEY ("recording_id", "chunk_number"),
123
+ CONSTRAINT "transcription_recording_chunks_authority_fk"
124
+ FOREIGN KEY ("account_id", "workspace_id", "subject_id", "recording_id")
125
+ REFERENCES "transcription_recordings"("account_id", "workspace_id", "subject_id", "id")
126
+ ON DELETE CASCADE,
127
+ CONSTRAINT "transcription_recording_chunks_object_fk"
128
+ FOREIGN KEY ("object_key")
129
+ REFERENCES "transcription_recording_objects"("object_key") ON DELETE RESTRICT,
130
+ CONSTRAINT "transcription_recording_chunks_state_check"
131
+ CHECK ("state" IN ('uploading', 'complete')),
132
+ CONSTRAINT "transcription_recording_chunks_values_check"
133
+ CHECK (
134
+ "chunk_number" >= 0
135
+ AND "byte_length" > 0
136
+ AND "start_milliseconds" >= 0
137
+ AND "duration_milliseconds" >= 0
138
+ AND "sha256" ~ '^[0-9a-f]{64}$'
139
+ AND octet_length("object_key") BETWEEN 1 AND 1024
140
+ AND (
141
+ ("state" = 'uploading' AND "completed_at" IS NULL)
142
+ OR ("state" = 'complete' AND "completed_at" IS NOT NULL)
143
+ )
144
+ ),
145
+ CONSTRAINT "transcription_recording_chunks_object_key_uq" UNIQUE ("object_key")
146
+ );
147
+
148
+ CREATE INDEX "transcription_recording_chunks_order_idx"
149
+ ON "transcription_recording_chunks" ("workspace_id", "recording_id", "chunk_number");
150
+
151
+ CREATE TABLE "transcription_recording_segments" (
152
+ "account_id" uuid NOT NULL,
153
+ "workspace_id" uuid NOT NULL,
154
+ "subject_id" text NOT NULL,
155
+ "recording_id" uuid NOT NULL,
156
+ "segment_number" integer NOT NULL,
157
+ "generation" integer NOT NULL,
158
+ "state" text NOT NULL DEFAULT 'preparing',
159
+ "byte_length" integer NOT NULL,
160
+ "sha256" text NOT NULL,
161
+ "start_milliseconds" integer NOT NULL,
162
+ "duration_milliseconds" integer NOT NULL,
163
+ "object_key" text NOT NULL,
164
+ "attempt_id" uuid,
165
+ "attempt_started_at" timestamptz,
166
+ "transcript_text" text,
167
+ "languages" jsonb NOT NULL DEFAULT '[]'::jsonb,
168
+ "provider_id" text,
169
+ "error_code" text,
170
+ "retryable" boolean NOT NULL DEFAULT false,
171
+ "created_at" timestamptz NOT NULL DEFAULT now(),
172
+ "updated_at" timestamptz NOT NULL DEFAULT now(),
173
+ CONSTRAINT "transcription_recording_segments_pk"
174
+ PRIMARY KEY ("recording_id", "segment_number"),
175
+ CONSTRAINT "transcription_recording_segments_authority_fk"
176
+ FOREIGN KEY ("account_id", "workspace_id", "subject_id", "recording_id")
177
+ REFERENCES "transcription_recordings"("account_id", "workspace_id", "subject_id", "id")
178
+ ON DELETE CASCADE,
179
+ CONSTRAINT "transcription_recording_segments_object_fk"
180
+ FOREIGN KEY ("object_key")
181
+ REFERENCES "transcription_recording_objects"("object_key") ON DELETE RESTRICT,
182
+ CONSTRAINT "transcription_recording_segments_state_check"
183
+ CHECK ("state" IN ('preparing', 'pending', 'transcribing', 'complete', 'failed')),
184
+ CONSTRAINT "transcription_recording_segments_values_check"
185
+ CHECK (
186
+ "segment_number" >= 0
187
+ AND "generation" > 0
188
+ AND "byte_length" > 0
189
+ AND "start_milliseconds" >= 0
190
+ AND "duration_milliseconds" > 0
191
+ AND "sha256" ~ '^[0-9a-f]{64}$'
192
+ AND octet_length("object_key") BETWEEN 1 AND 1024
193
+ AND ("transcript_text" IS NULL OR octet_length("transcript_text") <= 1000000)
194
+ AND jsonb_typeof("languages") = 'array'
195
+ AND (
196
+ ("attempt_id" IS NULL AND "attempt_started_at" IS NULL)
197
+ OR ("attempt_id" IS NOT NULL AND "attempt_started_at" IS NOT NULL)
198
+ )
199
+ ),
200
+ CONSTRAINT "transcription_recording_segments_error_code_check"
201
+ CHECK ("error_code" IS NULL OR "error_code" IN (
202
+ 'permission_denied', 'not_supported', 'network', 'provider', 'policy_blocked',
203
+ 'timeout', 'cancelled', 'unavailable', 'too_large', 'invalid_audio', 'unknown'
204
+ )),
205
+ CONSTRAINT "transcription_recording_segments_object_key_uq" UNIQUE ("object_key")
206
+ );
207
+
208
+ CREATE INDEX "transcription_recording_segments_order_idx"
209
+ ON "transcription_recording_segments" ("workspace_id", "recording_id", "segment_number");
210
+
211
+ ALTER TABLE "transcription_recordings" ENABLE ROW LEVEL SECURITY;
212
+ ALTER TABLE "transcription_recordings" FORCE ROW LEVEL SECURITY;
213
+ ALTER TABLE "transcription_recording_objects" ENABLE ROW LEVEL SECURITY;
214
+ ALTER TABLE "transcription_recording_objects" FORCE ROW LEVEL SECURITY;
215
+ ALTER TABLE "transcription_recording_chunks" ENABLE ROW LEVEL SECURITY;
216
+ ALTER TABLE "transcription_recording_chunks" FORCE ROW LEVEL SECURITY;
217
+ ALTER TABLE "transcription_recording_segments" ENABLE ROW LEVEL SECURITY;
218
+ ALTER TABLE "transcription_recording_segments" FORCE ROW LEVEL SECURITY;
219
+
220
+ CREATE POLICY transcription_recordings_subject_isolation ON "transcription_recordings"
221
+ USING (
222
+ opengeni_private.workspace_rls_visible("account_id", "workspace_id")
223
+ AND "subject_id" = nullif(current_setting('opengeni.subject_id', true), '')
224
+ )
225
+ WITH CHECK (
226
+ opengeni_private.workspace_rls_visible("account_id", "workspace_id")
227
+ AND "subject_id" = nullif(current_setting('opengeni.subject_id', true), '')
228
+ );
229
+
230
+ CREATE POLICY transcription_recording_objects_subject_isolation
231
+ ON "transcription_recording_objects"
232
+ USING (
233
+ opengeni_private.workspace_rls_visible("account_id", "workspace_id")
234
+ AND "subject_id" = nullif(current_setting('opengeni.subject_id', true), '')
235
+ )
236
+ WITH CHECK (
237
+ opengeni_private.workspace_rls_visible("account_id", "workspace_id")
238
+ AND "subject_id" = nullif(current_setting('opengeni.subject_id', true), '')
239
+ );
240
+
241
+ CREATE POLICY transcription_recording_chunks_subject_isolation
242
+ ON "transcription_recording_chunks"
243
+ USING (
244
+ opengeni_private.workspace_rls_visible("account_id", "workspace_id")
245
+ AND "subject_id" = nullif(current_setting('opengeni.subject_id', true), '')
246
+ )
247
+ WITH CHECK (
248
+ opengeni_private.workspace_rls_visible("account_id", "workspace_id")
249
+ AND "subject_id" = nullif(current_setting('opengeni.subject_id', true), '')
250
+ );
251
+
252
+ -- The existing provider-neutral object reaper needs one bounded cross-workspace
253
+ -- claim seam. The function returns only exact authority ids plus opaque storage
254
+ -- keys; no transcript, audio, credential, or provider metadata crosses the RLS
255
+ -- boundary. Recording rows are locked before object rows, matching request-side
256
+ -- mutation order. Expired recordings are fenced to discarded in the same claim.
257
+ -- The migration may target public or a dedicated embedded schema, so bind
258
+ -- current_schema() into every relation at creation time and expose only
259
+ -- pg_catalog at execution time.
260
+ DO $privileged_functions$
261
+ DECLARE data_schema text := current_schema();
262
+ BEGIN
263
+ EXECUTE format($ddl$
264
+ CREATE OR REPLACE FUNCTION opengeni_private.claim_due_transcription_recording_object_cleanup(
265
+ p_grace_ms bigint,
266
+ p_claim_timeout_ms bigint,
267
+ p_limit integer
268
+ )
269
+ RETURNS TABLE (
270
+ account_id uuid,
271
+ workspace_id uuid,
272
+ subject_id text,
273
+ recording_id uuid,
274
+ object_key text,
275
+ cleanup_claim_id uuid
276
+ )
277
+ LANGUAGE sql
278
+ SECURITY DEFINER
279
+ STRICT
280
+ SET search_path = pg_catalog
281
+ AS $function$
282
+ WITH due_recordings AS MATERIALIZED (
283
+ SELECT R.id, R.account_id, R.workspace_id, R.subject_id, R.expires_at
284
+ FROM %1$I.transcription_recordings R
285
+ WHERE EXISTS (
286
+ SELECT 1
287
+ FROM %1$I.transcription_recording_objects O
288
+ WHERE O.recording_id = R.id
289
+ AND O.cleaned_at IS NULL
290
+ AND O.cleanup_after <=
291
+ clock_timestamp() - greatest(p_grace_ms, 0) * interval '1 millisecond'
292
+ AND (
293
+ O.cleanup_claim_id IS NULL
294
+ OR O.cleanup_claimed_at <=
295
+ clock_timestamp() - greatest(p_claim_timeout_ms, 0) * interval '1 millisecond'
296
+ )
297
+ )
298
+ ORDER BY R.expires_at, R.id
299
+ FOR UPDATE OF R SKIP LOCKED
300
+ LIMIT least(greatest(p_limit, 0), 1000)
301
+ ), expired_recordings AS (
302
+ UPDATE %1$I.transcription_recordings R
303
+ SET state = 'discarded',
304
+ processing_owner = NULL,
305
+ processing_started_at = NULL,
306
+ error_code = NULL,
307
+ retryable = false,
308
+ updated_at = clock_timestamp()
309
+ FROM due_recordings D
310
+ WHERE R.id = D.id
311
+ AND D.expires_at <=
312
+ clock_timestamp() - greatest(p_grace_ms, 0) * interval '1 millisecond'
313
+ AND R.state <> 'discarded'
314
+ RETURNING R.id
315
+ ), candidates AS MATERIALIZED (
316
+ SELECT O.object_key
317
+ FROM %1$I.transcription_recording_objects O
318
+ JOIN due_recordings R ON R.id = O.recording_id
319
+ WHERE O.cleaned_at IS NULL
320
+ AND O.cleanup_after <=
321
+ clock_timestamp() - greatest(p_grace_ms, 0) * interval '1 millisecond'
322
+ AND (
323
+ O.cleanup_claim_id IS NULL
324
+ OR O.cleanup_claimed_at <=
325
+ clock_timestamp() - greatest(p_claim_timeout_ms, 0) * interval '1 millisecond'
326
+ )
327
+ ORDER BY O.cleanup_after, O.object_key
328
+ FOR UPDATE OF O SKIP LOCKED
329
+ LIMIT least(greatest(p_limit, 0), 1000)
330
+ ), claimed AS (
331
+ UPDATE %1$I.transcription_recording_objects O
332
+ SET cleanup_claim_id = gen_random_uuid(), cleanup_claimed_at = clock_timestamp()
333
+ FROM candidates C
334
+ WHERE O.object_key = C.object_key
335
+ RETURNING
336
+ O.account_id,
337
+ O.workspace_id,
338
+ O.subject_id,
339
+ O.recording_id,
340
+ O.object_key,
341
+ O.cleanup_claim_id
342
+ )
343
+ SELECT
344
+ C.account_id,
345
+ C.workspace_id,
346
+ C.subject_id,
347
+ C.recording_id,
348
+ C.object_key,
349
+ C.cleanup_claim_id
350
+ FROM claimed C, (SELECT count(*) FROM expired_recordings) AS fence;
351
+ $function$;
352
+ $ddl$, data_schema);
353
+
354
+ -- Once every object for an expired recording has been confirmed deleted,
355
+ -- purge the remaining manifest, chunk/segment metadata, provider pin, and
356
+ -- transcript. Provider deletion settles one object at a time first, so
357
+ -- metadata is never removed while an opaque object key still needs retryable
358
+ -- cleanup.
359
+ EXECUTE format($ddl$
360
+ CREATE OR REPLACE FUNCTION opengeni_private.purge_expired_transcription_recordings(
361
+ p_grace_ms bigint,
362
+ p_limit integer
363
+ )
364
+ RETURNS integer
365
+ LANGUAGE plpgsql
366
+ SECURITY DEFINER
367
+ STRICT
368
+ SET search_path = pg_catalog
369
+ AS $function$
370
+ DECLARE
371
+ v_recording_id uuid;
372
+ v_purged integer := 0;
373
+ BEGIN
374
+ IF p_grace_ms < 0 OR p_limit <= 0 THEN
375
+ RAISE EXCEPTION 'invalid transcription recording purge bounds';
376
+ END IF;
377
+
378
+ FOR v_recording_id IN
379
+ SELECT R.id
380
+ FROM %1$I.transcription_recordings R
381
+ WHERE R.expires_at <=
382
+ clock_timestamp() - p_grace_ms * interval '1 millisecond'
383
+ AND NOT EXISTS (
384
+ SELECT 1
385
+ FROM %1$I.transcription_recording_objects O
386
+ WHERE O.recording_id = R.id
387
+ AND O.cleaned_at IS NULL
388
+ )
389
+ ORDER BY R.expires_at, R.id
390
+ FOR UPDATE OF R SKIP LOCKED
391
+ LIMIT least(p_limit, 1000)
392
+ LOOP
393
+ DELETE FROM %1$I.transcription_recording_chunks
394
+ WHERE recording_id = v_recording_id;
395
+ DELETE FROM %1$I.transcription_recording_segments
396
+ WHERE recording_id = v_recording_id;
397
+ DELETE FROM %1$I.transcription_recording_objects
398
+ WHERE recording_id = v_recording_id;
399
+ DELETE FROM %1$I.transcription_recordings
400
+ WHERE id = v_recording_id;
401
+ v_purged := v_purged + 1;
402
+ END LOOP;
403
+
404
+ RETURN v_purged;
405
+ END;
406
+ $function$;
407
+ $ddl$, data_schema);
408
+ END
409
+ $privileged_functions$;
410
+
411
+ REVOKE ALL ON FUNCTION
412
+ opengeni_private.claim_due_transcription_recording_object_cleanup(bigint, bigint, integer)
413
+ FROM PUBLIC;
414
+
415
+ REVOKE ALL ON FUNCTION
416
+ opengeni_private.purge_expired_transcription_recordings(bigint, integer)
417
+ FROM PUBLIC;
418
+
419
+ DO $$
420
+ BEGIN
421
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
422
+ GRANT EXECUTE ON FUNCTION
423
+ opengeni_private.claim_due_transcription_recording_object_cleanup(bigint, bigint, integer)
424
+ TO opengeni_app;
425
+ GRANT EXECUTE ON FUNCTION
426
+ opengeni_private.purge_expired_transcription_recordings(bigint, integer)
427
+ TO opengeni_app;
428
+ END IF;
429
+ END $$;
430
+
431
+ CREATE POLICY transcription_recording_segments_subject_isolation
432
+ ON "transcription_recording_segments"
433
+ USING (
434
+ opengeni_private.workspace_rls_visible("account_id", "workspace_id")
435
+ AND "subject_id" = nullif(current_setting('opengeni.subject_id', true), '')
436
+ )
437
+ WITH CHECK (
438
+ opengeni_private.workspace_rls_visible("account_id", "workspace_id")
439
+ AND "subject_id" = nullif(current_setting('opengeni.subject_id', true), '')
440
+ );