@@ -14,6 +14,7 @@ export const EXTERNAL_CONVERSATION_BINDING_LIMIT = 500;
1414export const EXTERNAL_CONVERSATION_RELEASE_RECEIPT_LIMIT = 64 ;
1515export const EXTERNAL_CONVERSATION_RELEASE_RECEIPT_TOTAL_LIMIT =
1616 EXTERNAL_CONVERSATION_BINDING_LIMIT * EXTERNAL_CONVERSATION_RELEASE_RECEIPT_LIMIT ;
17+ export const EXTERNAL_CONVERSATION_RELEASE_RETRY_HORIZON_MS = 7 * 24 * 60 * 60 * 1_000 ;
1718
1819const SAFE_ID_PATTERN = / ^ [ A - Z a - z 0 - 9 _ - ] { 1 , 128 } $ / ;
1920const writerBrand : unique symbol = Symbol ( 'InteractiveExternalConversationAuthorityWriter' ) ;
@@ -208,6 +209,16 @@ class SqliteExternalConversationAuthority implements ExternalConversationAuthori
208209 const conversationDigest = digestConversationId ( conversationId ) ;
209210 assertSafeId ( operationId , 'External-conversation release operation id' ) ;
210211 return this . #lease. transaction ( 'write' , ( ) => {
212+ const now = Date . now ( ) ;
213+ // Exact reset deduplication is guaranteed for this explicit platform
214+ // retry horizon. Prune only expired receipts; reaching either bound
215+ // rejects a new reset before it can delete a binding.
216+ this . #lease. database
217+ . prepare ( `
218+ DELETE FROM external_conversation_release_receipts
219+ WHERE committed_at < ?
220+ ` )
221+ . run ( Math . max ( 0 , now - EXTERNAL_CONVERSATION_RELEASE_RETRY_HORIZON_MS ) ) ;
211222 const receipt = this . #lease. database
212223 . prepare ( `
213224 SELECT had_binding AS hadBinding
@@ -217,6 +228,31 @@ class SqliteExternalConversationAuthority implements ExternalConversationAuthori
217228 . get ( conversationDigest , operationId ) as { hadBinding ?: unknown } | undefined ;
218229 if ( receipt ) return Object . freeze ( { hadBinding : decodeBoolean ( receipt . hadBinding ) } ) ;
219230
231+ const conversationReceiptCount = this . #lease. database
232+ . prepare ( `
233+ SELECT COUNT(*) AS count
234+ FROM external_conversation_release_receipts
235+ WHERE conversation_digest = ?
236+ ` )
237+ . get ( conversationDigest ) as { count ?: unknown } ;
238+ const totalReceiptCount = this . #lease. database
239+ . prepare ( 'SELECT COUNT(*) AS count FROM external_conversation_release_receipts' )
240+ . get ( ) as { count ?: unknown } ;
241+ const perConversation = decodeCount (
242+ conversationReceiptCount . count ,
243+ 'external-conversation release receipt count' ,
244+ ) ;
245+ const total = decodeCount (
246+ totalReceiptCount . count ,
247+ 'external-conversation release receipt total count' ,
248+ ) ;
249+ if (
250+ perConversation >= EXTERNAL_CONVERSATION_RELEASE_RECEIPT_LIMIT ||
251+ total >= EXTERNAL_CONVERSATION_RELEASE_RECEIPT_TOTAL_LIMIT
252+ ) {
253+ throw new Error ( 'External-conversation release receipt capacity is full' ) ;
254+ }
255+
220256 const removed = this . #lease. database
221257 . prepare ( 'DELETE FROM external_conversation_bindings WHERE conversation_digest = ?' )
222258 . run ( conversationDigest ) . changes ;
@@ -230,44 +266,7 @@ class SqliteExternalConversationAuthority implements ExternalConversationAuthori
230266 conversation_digest, operation_id, had_binding, committed_at
231267 ) VALUES (?, ?, ?, ?)
232268 ` )
233- . run ( conversationDigest , operationId , hadBinding ? 1 : 0 , Date . now ( ) ) ;
234- this . #lease. database
235- . prepare ( `
236- DELETE FROM external_conversation_release_receipts
237- WHERE conversation_digest = ?
238- AND operation_id NOT IN (
239- SELECT operation_id
240- FROM external_conversation_release_receipts
241- WHERE conversation_digest = ?
242- ORDER BY committed_at DESC, operation_id DESC
243- LIMIT ?
244- )
245- ` )
246- . run ( conversationDigest , conversationDigest , EXTERNAL_CONVERSATION_RELEASE_RECEIPT_LIMIT ) ;
247- const receiptCount = this . #lease. database
248- . prepare ( 'SELECT COUNT(*) AS count FROM external_conversation_release_receipts' )
249- . get ( ) as { count ?: unknown } ;
250- if (
251- typeof receiptCount . count !== 'number' ||
252- ! Number . isSafeInteger ( receiptCount . count ) ||
253- receiptCount . count < 0
254- ) {
255- throw new Error ( 'Invalid external-conversation release receipt count' ) ;
256- }
257- const excess = receiptCount . count - EXTERNAL_CONVERSATION_RELEASE_RECEIPT_TOTAL_LIMIT ;
258- if ( excess > 0 ) {
259- this . #lease. database
260- . prepare ( `
261- DELETE FROM external_conversation_release_receipts
262- WHERE rowid IN (
263- SELECT rowid
264- FROM external_conversation_release_receipts
265- ORDER BY committed_at ASC, conversation_digest ASC, operation_id ASC
266- LIMIT ?
267- )
268- ` )
269- . run ( excess ) ;
270- }
269+ . run ( conversationDigest , operationId , hadBinding ? 1 : 0 , now ) ;
271270 return Object . freeze ( { hadBinding } ) ;
272271 } ) ;
273272 }
@@ -342,6 +341,13 @@ class SqliteExternalConversationAuthority implements ExternalConversationAuthori
342341 }
343342}
344343
344+ function decodeCount ( value : unknown , label : string ) : number {
345+ if ( typeof value !== 'number' || ! Number . isSafeInteger ( value ) || value < 0 ) {
346+ throw new Error ( `Invalid ${ label } ` ) ;
347+ }
348+ return value ;
349+ }
350+
345351function digestConversationId ( conversationId : string ) : string {
346352 if (
347353 typeof conversationId !== 'string' ||
0 commit comments