11import { createHash } from "node:crypto" ;
2+ import type { CardTextSide } from "../../cards" ;
23import type { GeneratedCardImageOperationMetadata } from "./types" ;
34
45const generatedCardImageOperationNamespace = "flashcards-open-source-app:generated-card-image:v1" ;
6+ const generatedCardImageRequestBucketMs = 3_600_000 ;
7+
8+ export type GeneratedCardImageRequestContent = Readonly < {
9+ workspaceId : string ;
10+ cardId : string ;
11+ targetSide : CardTextSide ;
12+ imagePrompt : string ;
13+ altText : string ;
14+ requestedAtMs : number ;
15+ } > ;
516
617function deterministicUuidFromOperationIdentity (
718 purpose : "operation" | "media-asset" ,
8- runId : string ,
9- operationKey : string ,
19+ identity : ReadonlyArray < string | number > ,
1020) : string {
1121 const digest = createHash ( "sha256" )
1222 . update ( JSON . stringify (
13- [ generatedCardImageOperationNamespace , purpose , runId . toLowerCase ( ) , operationKey ] ,
23+ [ generatedCardImageOperationNamespace , purpose , ... identity ] ,
1424 ) )
1525 . digest ( ) ;
1626 const uuidBytes = Buffer . from ( digest . subarray ( 0 , 16 ) ) ;
@@ -27,15 +37,49 @@ function deterministicUuidFromOperationIdentity(
2737 ] . join ( "-" ) ;
2838}
2939
30- export function deriveGeneratedCardImageOperationMetadata (
31- runId : string ,
32- operationKey : string ,
40+ function deriveOperationMetadataFromIdentity (
41+ identityKind : GeneratedCardImageOperationMetadata [ "identityKind" ] ,
42+ identity : ReadonlyArray < string | number > ,
3343) : GeneratedCardImageOperationMetadata {
34- const operationId = deterministicUuidFromOperationIdentity ( "operation" , runId , operationKey ) ;
44+ const operationId = deterministicUuidFromOperationIdentity ( "operation" , identity ) ;
3545 return {
46+ identityKind,
3647 operationId,
37- mediaAssetId : deterministicUuidFromOperationIdentity ( "media-asset" , runId , operationKey ) ,
48+ mediaAssetId : deterministicUuidFromOperationIdentity ( "media-asset" , identity ) ,
3849 mediaLastOperationId : `generated-card-image:${ operationId } :media` ,
3950 cardLastOperationId : `generated-card-image:${ operationId } :card` ,
4051 } ;
4152}
53+
54+ export function deriveGeneratedCardImageOperationMetadata (
55+ runId : string ,
56+ operationKey : string ,
57+ ) : GeneratedCardImageOperationMetadata {
58+ return deriveOperationMetadataFromIdentity ( "chat_run" , [ runId . toLowerCase ( ) , operationKey ] ) ;
59+ }
60+
61+ /**
62+ * Identity for surfaces without a chat run. `requestedAtMs` is the backend clock at request
63+ * receipt, bucketed into epoch-aligned UTC hours: a literal repeat inside the hour is the same
64+ * operation, while a retry that lands in the next hour is a new, separately paid operation.
65+ */
66+ export function deriveRequestContentGeneratedCardImageOperationMetadata (
67+ request : GeneratedCardImageRequestContent ,
68+ ) : GeneratedCardImageOperationMetadata {
69+ if ( ! Number . isSafeInteger ( request . requestedAtMs ) || request . requestedAtMs < 0 ) {
70+ throw new RangeError (
71+ "Generated card image requestedAtMs must be a non-negative epoch-millisecond safe integer." ,
72+ ) ;
73+ }
74+ const contentSha256 = createHash ( "sha256" )
75+ . update ( JSON . stringify ( [ request . imagePrompt , request . altText ] ) )
76+ . digest ( "hex" ) ;
77+ return deriveOperationMetadataFromIdentity ( "request_content" , [
78+ "request-content" ,
79+ request . workspaceId . toLowerCase ( ) ,
80+ request . cardId . toLowerCase ( ) ,
81+ request . targetSide ,
82+ contentSha256 ,
83+ Math . floor ( request . requestedAtMs / generatedCardImageRequestBucketMs ) ,
84+ ] ) ;
85+ }
0 commit comments